tep 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- metadata +264 -0
data/test/test_pg.rb
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
# Tep::PG end-to-end against a real PostgreSQL instance.
|
|
4
|
+
#
|
|
5
|
+
# Gated on PG_TEST_URL -- set to a libpq conninfo string when running:
|
|
6
|
+
#
|
|
7
|
+
# PG_TEST_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \
|
|
8
|
+
# ruby test/test_pg.rb
|
|
9
|
+
#
|
|
10
|
+
# `make test-pg` (Makefile target) spins up a postgres:16 docker
|
|
11
|
+
# container, sets the env var, runs this file, tears down on exit.
|
|
12
|
+
#
|
|
13
|
+
# Without PG_TEST_URL set, every test in this class skips cleanly --
|
|
14
|
+
# `make test` on a contributor's machine that doesn't have PG running
|
|
15
|
+
# still passes its other test classes.
|
|
16
|
+
#
|
|
17
|
+
# Each test uses a per-class temp table whose name carries the PID so
|
|
18
|
+
# parallel test files don't collide. Test order is randomised via
|
|
19
|
+
# minitest seed; the on_start hook seeds the table and individual
|
|
20
|
+
# tests are written to be order-independent (each one re-asserts the
|
|
21
|
+
# count it expects).
|
|
22
|
+
class TestPg < TepTest
|
|
23
|
+
PG_URL = ENV["PG_TEST_URL"]
|
|
24
|
+
|
|
25
|
+
# Skip the whole class cleanly when PG isn't available. Override
|
|
26
|
+
# setup BEFORE TepTest's setup runs `boot!` (which tries to compile
|
|
27
|
+
# the inline app_source). With PG_TEST_URL unset, every test method
|
|
28
|
+
# short-circuits to a single skip.
|
|
29
|
+
def setup
|
|
30
|
+
if PG_URL.nil? || PG_URL.empty?
|
|
31
|
+
skip "PG_TEST_URL not set (e.g. PG_TEST_URL=postgresql:///postgres). " \
|
|
32
|
+
"See test/test_pg.rb header for the docker recipe."
|
|
33
|
+
end
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
TBL = "tep_test_pg_#{$$}"
|
|
38
|
+
|
|
39
|
+
# Build the app source with the PG_URL + table name interpolated in
|
|
40
|
+
# at class load time. The heredoc body is a regular Ruby string in
|
|
41
|
+
# the harness; the inline-quoted constants land as string literals
|
|
42
|
+
# in the compiled binary.
|
|
43
|
+
app_source <<~RB
|
|
44
|
+
require 'sinatra'
|
|
45
|
+
|
|
46
|
+
# The PG test app runs under the default prefork server. We
|
|
47
|
+
# exercise the async surface explicitly via /async_exec and
|
|
48
|
+
# /async_params routes (which call Connection#async_exec
|
|
49
|
+
# directly); io_wait falls back to single-shot poll(2)
|
|
50
|
+
# outside scheduled context so async correctness is
|
|
51
|
+
# measurable here regardless of server choice. The
|
|
52
|
+
# multi-fiber concurrency win that scheduled gives is
|
|
53
|
+
# measured in bench/pg_pool_bench.rb (which DOES boot under
|
|
54
|
+
# Scheduled).
|
|
55
|
+
|
|
56
|
+
PG_URL = "#{PG_URL}"
|
|
57
|
+
TBL = "#{TBL}"
|
|
58
|
+
|
|
59
|
+
on_start do
|
|
60
|
+
c = PG.connect(PG_URL)
|
|
61
|
+
if c.connected?
|
|
62
|
+
# Drop on every boot so re-running tests is idempotent.
|
|
63
|
+
r = c.exec("DROP TABLE IF EXISTS " + TBL)
|
|
64
|
+
r.clear
|
|
65
|
+
r = c.exec("CREATE TABLE " + TBL +
|
|
66
|
+
" (id SERIAL PRIMARY KEY, body TEXT NOT NULL, n INTEGER, opt TEXT)")
|
|
67
|
+
r.clear
|
|
68
|
+
# Seed three rows so reads-without-prior-writes have something.
|
|
69
|
+
r = c.exec_params("INSERT INTO " + TBL + " (body, n, opt) VALUES ($1, $2, $3)",
|
|
70
|
+
["alpha", 1, "first"])
|
|
71
|
+
r.clear
|
|
72
|
+
r = c.exec_params("INSERT INTO " + TBL + " (body, n, opt) VALUES ($1, $2, $3)",
|
|
73
|
+
["beta", 2, nil])
|
|
74
|
+
r.clear
|
|
75
|
+
r = c.exec_params("INSERT INTO " + TBL + " (body, n, opt) VALUES ($1, $2, $3)",
|
|
76
|
+
["gamma's", 3, "third"])
|
|
77
|
+
r.clear
|
|
78
|
+
c.close
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# GET /version -- banner-style libpq + server version.
|
|
83
|
+
get '/version' do
|
|
84
|
+
c = PG.connect(PG_URL)
|
|
85
|
+
out = "libpq=" + PG.libpq_version + " server=" + c.server_version.to_s
|
|
86
|
+
c.close
|
|
87
|
+
out
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# GET /connect_ok -- did the connect succeed?
|
|
91
|
+
get '/connect_ok' do
|
|
92
|
+
c = PG.connect(PG_URL)
|
|
93
|
+
out = c.connected? ? "ok" : ("fail:" + c.last_error_message)
|
|
94
|
+
c.close
|
|
95
|
+
out
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# GET /select_const -- one-row, two-col round-trip.
|
|
99
|
+
get '/select_const' do
|
|
100
|
+
c = PG.connect(PG_URL)
|
|
101
|
+
r = c.exec("SELECT 1 AS one, 'hello' AS greeting")
|
|
102
|
+
out = "rows=" + r.ntuples.to_s + " cols=" + r.nfields.to_s +
|
|
103
|
+
" row0=[" + r.getvalue(0, 0) + "," + r.getvalue(0, 1) + "]"
|
|
104
|
+
r.clear
|
|
105
|
+
c.close
|
|
106
|
+
out
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# GET /seed_count -- number of seeded rows (>= 3).
|
|
110
|
+
get '/seed_count' do
|
|
111
|
+
c = PG.connect(PG_URL)
|
|
112
|
+
r = c.exec("SELECT count(*) FROM " + TBL)
|
|
113
|
+
n = r.getvalue(0, 0)
|
|
114
|
+
r.clear
|
|
115
|
+
c.close
|
|
116
|
+
"count=" + n
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# GET /iter -- indexed iteration via getvalue. PG::Result#each_row
|
|
120
|
+
# and #each are blocked on matz/spinel#628 (yield-of-typed-container
|
|
121
|
+
# loses type at the block-local binding); both return wrong values
|
|
122
|
+
# silently today. The methods stay defined in pg.rb so they light
|
|
123
|
+
# up automatically when #628 lands; for now the v1 iteration story
|
|
124
|
+
# is the explicit while loop below.
|
|
125
|
+
get '/iter' do
|
|
126
|
+
c = PG.connect(PG_URL)
|
|
127
|
+
r = c.exec("SELECT body FROM " + TBL + " ORDER BY id")
|
|
128
|
+
out = ""
|
|
129
|
+
i = 0
|
|
130
|
+
n = r.ntuples
|
|
131
|
+
while i < n
|
|
132
|
+
out = out + r.getvalue(i, 0) + ","
|
|
133
|
+
i += 1
|
|
134
|
+
end
|
|
135
|
+
r.clear
|
|
136
|
+
c.close
|
|
137
|
+
"bodies=" + out
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# GET /fields_and_fnumber -- shape of fields + fnumber lookup.
|
|
141
|
+
get '/fields_and_fnumber' do
|
|
142
|
+
c = PG.connect(PG_URL)
|
|
143
|
+
r = c.exec("SELECT id, body, n, opt FROM " + TBL + " LIMIT 1")
|
|
144
|
+
out = "fields=" + r.fields.join(",") +
|
|
145
|
+
" fnumber_body=" + r.fnumber("body").to_s +
|
|
146
|
+
" fnumber_missing=" + r.fnumber("nope").to_s
|
|
147
|
+
r.clear
|
|
148
|
+
c.close
|
|
149
|
+
out
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# GET /values -- the full values() shape.
|
|
153
|
+
get '/values' do
|
|
154
|
+
c = PG.connect(PG_URL)
|
|
155
|
+
r = c.exec("SELECT body FROM " + TBL + " WHERE body IN ('alpha','beta') ORDER BY body")
|
|
156
|
+
v = r.values
|
|
157
|
+
out = "type=array(" + v.length.to_s + "x" + v[0].length.to_s + ") " +
|
|
158
|
+
"row0_col0=" + v[0][0] + " row1_col0=" + v[1][0]
|
|
159
|
+
r.clear
|
|
160
|
+
c.close
|
|
161
|
+
out
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# GET /column_values -- single-column slice.
|
|
165
|
+
get '/column_values' do
|
|
166
|
+
c = PG.connect(PG_URL)
|
|
167
|
+
r = c.exec("SELECT body FROM " + TBL + " ORDER BY id")
|
|
168
|
+
cv = r.column_values(0)
|
|
169
|
+
out = "len=" + cv.length.to_s + " first=" + cv[0] + " last=" + cv[cv.length - 1]
|
|
170
|
+
r.clear
|
|
171
|
+
c.close
|
|
172
|
+
out
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# GET /null -- find the seeded row with opt IS NULL via
|
|
176
|
+
# getisnull. The seeded "beta" row (id <= 3 by construction)
|
|
177
|
+
# is the canonical NULL holder; other tests may insert more
|
|
178
|
+
# NULL-opt rows so we filter by id to keep this deterministic.
|
|
179
|
+
get '/null' do
|
|
180
|
+
c = PG.connect(PG_URL)
|
|
181
|
+
r = c.exec("SELECT body, opt FROM " + TBL + " WHERE id <= 3 ORDER BY id")
|
|
182
|
+
found = "none"
|
|
183
|
+
n = r.ntuples
|
|
184
|
+
i = 0
|
|
185
|
+
while i < n
|
|
186
|
+
if r.getisnull(i, 1)
|
|
187
|
+
found = r.getvalue(i, 0)
|
|
188
|
+
end
|
|
189
|
+
i += 1
|
|
190
|
+
end
|
|
191
|
+
r.clear
|
|
192
|
+
c.close
|
|
193
|
+
"null_opt_for=" + found
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# POST /insert -- exec_params with positional binds, RETURNING id.
|
|
197
|
+
post '/insert' do
|
|
198
|
+
c = PG.connect(PG_URL)
|
|
199
|
+
r = c.exec_params(
|
|
200
|
+
"INSERT INTO " + TBL + " (body, n) VALUES ($1, $2) RETURNING id",
|
|
201
|
+
[params[:body], params[:n].to_i])
|
|
202
|
+
id = r.getvalue(0, 0)
|
|
203
|
+
r.clear
|
|
204
|
+
c.close
|
|
205
|
+
"inserted_id=" + id
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# GET /by_id/:id -- read-back the inserted row.
|
|
209
|
+
get '/by_id/:id' do
|
|
210
|
+
c = PG.connect(PG_URL)
|
|
211
|
+
r = c.exec_params("SELECT body, n FROM " + TBL + " WHERE id = $1",
|
|
212
|
+
[params[:id]])
|
|
213
|
+
if r.ntuples == 0
|
|
214
|
+
out = "not_found"
|
|
215
|
+
else
|
|
216
|
+
out = "body=" + r.getvalue(0, 0) + " n=" + r.getvalue(0, 1)
|
|
217
|
+
end
|
|
218
|
+
r.clear
|
|
219
|
+
c.close
|
|
220
|
+
out
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# GET /int_round_trip -- exec_params with an Integer; libpq's
|
|
224
|
+
# text format means the returned getvalue is the string "42".
|
|
225
|
+
get '/int_round_trip' do
|
|
226
|
+
c = PG.connect(PG_URL)
|
|
227
|
+
r = c.exec_params("SELECT $1::int + 0", [42])
|
|
228
|
+
out = r.getvalue(0, 0)
|
|
229
|
+
r.clear
|
|
230
|
+
c.close
|
|
231
|
+
"val=" + out
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# GET /quote_string -- params containing single quotes don't
|
|
235
|
+
# break the SQL (proves binds aren't string-interpolated).
|
|
236
|
+
get '/quote_string' do
|
|
237
|
+
c = PG.connect(PG_URL)
|
|
238
|
+
r = c.exec_params("SELECT $1::text", ["O'Brien"])
|
|
239
|
+
out = r.getvalue(0, 0)
|
|
240
|
+
r.clear
|
|
241
|
+
c.close
|
|
242
|
+
"val=" + out
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# GET /escape_literal -- in case anyone DOES need to interpolate.
|
|
246
|
+
get '/escape_literal' do
|
|
247
|
+
c = PG.connect(PG_URL)
|
|
248
|
+
out = c.escape_literal("O'Brien")
|
|
249
|
+
c.close
|
|
250
|
+
"lit=" + out
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# GET /escape_identifier -- table/column names.
|
|
254
|
+
get '/escape_identifier' do
|
|
255
|
+
c = PG.connect(PG_URL)
|
|
256
|
+
out = c.escape_identifier("users")
|
|
257
|
+
c.close
|
|
258
|
+
"ident=" + out
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# GET /missing_table -- error path; exec raises PG::UndefinedTable.
|
|
262
|
+
get '/missing_table' do
|
|
263
|
+
c = PG.connect(PG_URL)
|
|
264
|
+
out = ""
|
|
265
|
+
begin
|
|
266
|
+
r = c.exec("SELECT * FROM tep_no_such_table_anywhere")
|
|
267
|
+
r.clear
|
|
268
|
+
out = "raised=no"
|
|
269
|
+
rescue PG::UndefinedTable => e
|
|
270
|
+
out = "raised=UndefinedTable" +
|
|
271
|
+
" sqlstate=" + c.last_sqlstate +
|
|
272
|
+
" match42P01=" + (c.last_sqlstate == "42P01" ? "yes" : "no") +
|
|
273
|
+
" is_pg_error=" + (e.is_a?(PG::Error) ? "yes" : "no")
|
|
274
|
+
end
|
|
275
|
+
c.close
|
|
276
|
+
out
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# GET /unique_violation -- duplicate PK INSERT raises
|
|
280
|
+
# PG::UniqueViolation (SQLSTATE 23505).
|
|
281
|
+
get '/unique_violation' do
|
|
282
|
+
c = PG.connect(PG_URL)
|
|
283
|
+
# Clear any leftover row from a prior crashed run so the first
|
|
284
|
+
# INSERT below reliably succeeds.
|
|
285
|
+
rd = c.exec_params("DELETE FROM " + TBL + " WHERE id = $1", ["99001"])
|
|
286
|
+
rd.clear
|
|
287
|
+
r1 = c.exec_params("INSERT INTO " + TBL + " (id, body) VALUES ($1, $2)",
|
|
288
|
+
["99001", "duplicate"])
|
|
289
|
+
r1.clear
|
|
290
|
+
out = ""
|
|
291
|
+
begin
|
|
292
|
+
r2 = c.exec_params("INSERT INTO " + TBL + " (id, body) VALUES ($1, $2)",
|
|
293
|
+
["99001", "duplicate"])
|
|
294
|
+
r2.clear
|
|
295
|
+
out = "first_ok=yes second_raised=no"
|
|
296
|
+
rescue PG::UniqueViolation => e
|
|
297
|
+
out = "first_ok=yes second_raised=UniqueViolation" +
|
|
298
|
+
" sqlstate=" + c.last_sqlstate +
|
|
299
|
+
" is_pg_error=" + (e.is_a?(PG::Error) ? "yes" : "no")
|
|
300
|
+
end
|
|
301
|
+
r3 = c.exec_params("DELETE FROM " + TBL + " WHERE id = $1", ["99001"])
|
|
302
|
+
r3.clear
|
|
303
|
+
c.close
|
|
304
|
+
out
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# GET /tx_commit -- transaction with COMMIT writes survive.
|
|
308
|
+
get '/tx_commit' do
|
|
309
|
+
c = PG.connect(PG_URL)
|
|
310
|
+
r = c.exec("BEGIN"); r.clear
|
|
311
|
+
r = c.exec_params("INSERT INTO " + TBL + " (body) VALUES ($1) RETURNING id",
|
|
312
|
+
["tx-commit-row"])
|
|
313
|
+
id = r.getvalue(0, 0)
|
|
314
|
+
r.clear
|
|
315
|
+
r = c.exec("COMMIT"); r.clear
|
|
316
|
+
# Read back in a fresh statement (already inside same conn, fine).
|
|
317
|
+
r = c.exec_params("SELECT body FROM " + TBL + " WHERE id = $1", [id])
|
|
318
|
+
out = "id=" + id + " body=" + r.getvalue(0, 0)
|
|
319
|
+
r.clear
|
|
320
|
+
r = c.exec_params("DELETE FROM " + TBL + " WHERE id = $1", [id])
|
|
321
|
+
r.clear
|
|
322
|
+
c.close
|
|
323
|
+
out
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# GET /tx_rollback -- transaction with ROLLBACK doesn't persist.
|
|
327
|
+
get '/tx_rollback' do
|
|
328
|
+
c = PG.connect(PG_URL)
|
|
329
|
+
r = c.exec("BEGIN"); r.clear
|
|
330
|
+
r = c.exec_params("INSERT INTO " + TBL + " (body) VALUES ($1) RETURNING id",
|
|
331
|
+
["tx-rollback-row"])
|
|
332
|
+
id = r.getvalue(0, 0)
|
|
333
|
+
r.clear
|
|
334
|
+
r = c.exec("ROLLBACK"); r.clear
|
|
335
|
+
# Should NOT be in the table now.
|
|
336
|
+
r = c.exec_params("SELECT count(*) FROM " + TBL + " WHERE id = $1", [id])
|
|
337
|
+
n = r.getvalue(0, 0)
|
|
338
|
+
r.clear
|
|
339
|
+
c.close
|
|
340
|
+
"after_rollback_count=" + n
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# GET /cmd_status -- COMMAND-style result reports the libpq
|
|
344
|
+
# cmd_status string for an UPDATE.
|
|
345
|
+
get '/cmd_status' do
|
|
346
|
+
c = PG.connect(PG_URL)
|
|
347
|
+
r = c.exec_params("UPDATE " + TBL + " SET n = n WHERE body = $1", ["alpha"])
|
|
348
|
+
out = "status=[" + r.cmd_status + "] tuples=" + r.cmd_tuples.to_s
|
|
349
|
+
r.clear
|
|
350
|
+
c.close
|
|
351
|
+
out
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# GET /many_results -- open many concurrent results to verify
|
|
355
|
+
# the slot table doesn't fall over under load.
|
|
356
|
+
get '/many_results' do
|
|
357
|
+
c = PG.connect(PG_URL)
|
|
358
|
+
held = [0]
|
|
359
|
+
held.delete_at(0)
|
|
360
|
+
i = 0
|
|
361
|
+
while i < 20
|
|
362
|
+
r = c.exec("SELECT " + i.to_s)
|
|
363
|
+
held.push(r.rh)
|
|
364
|
+
i += 1
|
|
365
|
+
end
|
|
366
|
+
# Now read each held result + free.
|
|
367
|
+
sum = 0
|
|
368
|
+
j = 0
|
|
369
|
+
while j < held.length
|
|
370
|
+
r = PG::Result.new(held[j])
|
|
371
|
+
sum += r.getvalue(0, 0).to_i
|
|
372
|
+
r.clear
|
|
373
|
+
j += 1
|
|
374
|
+
end
|
|
375
|
+
c.close
|
|
376
|
+
"sum=" + sum.to_s
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# -------- PG::Pool routes --------
|
|
380
|
+
POOL = PG::Pool.new(PG_URL, 4)
|
|
381
|
+
|
|
382
|
+
# GET /pool_size -- pool was constructed with 4 conns; all
|
|
383
|
+
# should be open + healthy.
|
|
384
|
+
get '/pool_size' do
|
|
385
|
+
"size=" + POOL.size.to_s +
|
|
386
|
+
" available=" + POOL.available.to_s +
|
|
387
|
+
" healthy=" + (POOL.healthy? ? "yes" : "no")
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# GET /pool_query -- checkout, run a query, checkin. Verifies
|
|
391
|
+
# the pool conns are usable.
|
|
392
|
+
get '/pool_query' do
|
|
393
|
+
c = POOL.checkout
|
|
394
|
+
r = c.exec("SELECT 1 AS one, 'pool' AS src")
|
|
395
|
+
out = r.getvalue(0, 0) + "/" + r.getvalue(0, 1)
|
|
396
|
+
r.clear
|
|
397
|
+
POOL.checkin(c)
|
|
398
|
+
"val=" + out
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# GET /pool_drain_refill -- checkout 2, observe drained
|
|
402
|
+
# count, checkin both, observe refilled count.
|
|
403
|
+
get '/pool_drain_refill' do
|
|
404
|
+
n = POOL.size
|
|
405
|
+
c1 = POOL.checkout
|
|
406
|
+
c2 = POOL.checkout
|
|
407
|
+
drained_avail = POOL.available
|
|
408
|
+
POOL.checkin(c2)
|
|
409
|
+
POOL.checkin(c1)
|
|
410
|
+
refilled_avail = POOL.available
|
|
411
|
+
"size=" + n.to_s +
|
|
412
|
+
" drained=" + drained_avail.to_s +
|
|
413
|
+
" refilled=" + refilled_avail.to_s
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# GET /async_exec -- explicit async path. Under Scheduled
|
|
417
|
+
# this exercises PQsendQuery + io_wait; under prefork it's
|
|
418
|
+
# still correct (io_wait falls back to a single-shot poll).
|
|
419
|
+
get '/async_exec' do
|
|
420
|
+
c = POOL.checkout
|
|
421
|
+
r = c.async_exec("SELECT 'async-hello'")
|
|
422
|
+
out = r.getvalue(0, 0)
|
|
423
|
+
r.clear
|
|
424
|
+
POOL.checkin(c)
|
|
425
|
+
"val=" + out
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# GET /async_params -- async with $1 bind.
|
|
429
|
+
get '/async_params/:n' do
|
|
430
|
+
c = POOL.checkout
|
|
431
|
+
r = c.async_exec_params("SELECT $1::int * 7", [params[:n]])
|
|
432
|
+
out = r.getvalue(0, 0)
|
|
433
|
+
r.clear
|
|
434
|
+
POOL.checkin(c)
|
|
435
|
+
"val=" + out
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# GET /pool_reusable -- a conn returned to the pool is
|
|
439
|
+
# actually usable again. checkout, exec, checkin, checkout,
|
|
440
|
+
# exec -- verify the second exec works.
|
|
441
|
+
get '/pool_reusable' do
|
|
442
|
+
c1 = POOL.checkout
|
|
443
|
+
r1 = c1.exec("SELECT 1")
|
|
444
|
+
v1 = r1.getvalue(0, 0)
|
|
445
|
+
r1.clear
|
|
446
|
+
POOL.checkin(c1)
|
|
447
|
+
c2 = POOL.checkout
|
|
448
|
+
r2 = c2.exec("SELECT 2")
|
|
449
|
+
v2 = r2.getvalue(0, 0)
|
|
450
|
+
r2.clear
|
|
451
|
+
POOL.checkin(c2)
|
|
452
|
+
"first=" + v1 + " second=" + v2
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# GET /pool_exhaust -- drain every connection, then a further
|
|
456
|
+
# checkout past the (lowered) timeout raises PG::PoolExhausted.
|
|
457
|
+
# Verifies the raise is rescuable both as the exact class and via
|
|
458
|
+
# the PG::Error parent. Restores the pool + timeout before
|
|
459
|
+
# returning so the route is idempotent across test runs.
|
|
460
|
+
get '/pool_exhaust' do
|
|
461
|
+
POOL.set_checkout_timeout_ms(1)
|
|
462
|
+
held = []
|
|
463
|
+
n = POOL.size
|
|
464
|
+
i = 0
|
|
465
|
+
while i < n
|
|
466
|
+
held.push(POOL.checkout)
|
|
467
|
+
i += 1
|
|
468
|
+
end
|
|
469
|
+
exact = "no"
|
|
470
|
+
parent = "no"
|
|
471
|
+
begin
|
|
472
|
+
POOL.checkout
|
|
473
|
+
rescue PG::PoolExhausted => e
|
|
474
|
+
exact = "yes"
|
|
475
|
+
end
|
|
476
|
+
begin
|
|
477
|
+
POOL.checkout
|
|
478
|
+
rescue PG::Error => e
|
|
479
|
+
parent = "yes"
|
|
480
|
+
end
|
|
481
|
+
while held.length > 0
|
|
482
|
+
POOL.checkin(held.delete_at(0))
|
|
483
|
+
end
|
|
484
|
+
POOL.set_checkout_timeout_ms(5000)
|
|
485
|
+
"exact=" + exact + " parent=" + parent
|
|
486
|
+
end
|
|
487
|
+
RB
|
|
488
|
+
|
|
489
|
+
def test_connect_succeeds
|
|
490
|
+
res = get("/connect_ok")
|
|
491
|
+
assert_equal "200", res.code
|
|
492
|
+
assert_equal "ok", res.body
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def test_libpq_and_server_version_render
|
|
496
|
+
res = get("/version")
|
|
497
|
+
assert_equal "200", res.code
|
|
498
|
+
# libpq= matches major.minor.patch; server is an integer >= 100000.
|
|
499
|
+
assert_match(/\Alibpq=\d+\.\d+\.\d+ server=\d{5,}\z/, res.body)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def test_select_const_round_trips
|
|
503
|
+
res = get("/select_const")
|
|
504
|
+
assert_equal "200", res.code
|
|
505
|
+
assert_equal "rows=1 cols=2 row0=[1,hello]", res.body
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def test_seed_rows_present
|
|
509
|
+
res = get("/seed_count")
|
|
510
|
+
assert_equal "200", res.code
|
|
511
|
+
n = res.body.split("=").last.to_i
|
|
512
|
+
assert n >= 3, "expected >= 3 seeded rows, got #{n} (body=#{res.body})"
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def test_indexed_iteration_via_getvalue
|
|
516
|
+
res = get("/iter")
|
|
517
|
+
assert_match(/^bodies=/, res.body)
|
|
518
|
+
bodies = res.body.split("=", 2).last
|
|
519
|
+
assert_includes bodies, "alpha"
|
|
520
|
+
assert_includes bodies, "beta"
|
|
521
|
+
assert_includes bodies, "gamma's"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# test_each_row_yields_array / test_each_yields_hash -- deferred.
|
|
525
|
+
# Both block on matz/spinel#628 (yield of typed Array / Hash loses
|
|
526
|
+
# type at the block-local binding). The methods stay in pg.rb;
|
|
527
|
+
# tests light up automatically when #628 lands.
|
|
528
|
+
|
|
529
|
+
def test_fields_and_fnumber
|
|
530
|
+
res = get("/fields_and_fnumber")
|
|
531
|
+
assert_match(/fields=id,body,n,opt /, res.body)
|
|
532
|
+
assert_includes res.body, "fnumber_body=1"
|
|
533
|
+
assert_includes res.body, "fnumber_missing=-1"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def test_values_shape
|
|
537
|
+
res = get("/values")
|
|
538
|
+
assert_match(/\Atype=array\(2x1\)/, res.body)
|
|
539
|
+
assert_includes res.body, "row0_col0=alpha"
|
|
540
|
+
assert_includes res.body, "row1_col0=beta"
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def test_column_values_returns_array
|
|
544
|
+
res = get("/column_values")
|
|
545
|
+
body = res.body
|
|
546
|
+
assert_match(/^len=\d+ /, body)
|
|
547
|
+
assert_includes body, "first=alpha"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def test_null_detected_via_getisnull
|
|
551
|
+
res = get("/null")
|
|
552
|
+
# Seeded "beta" row has opt=NULL.
|
|
553
|
+
assert_equal "null_opt_for=beta", res.body
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def test_exec_params_quoted_string_round_trip
|
|
557
|
+
res = get("/quote_string")
|
|
558
|
+
assert_equal "val=O'Brien", res.body
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def test_exec_params_int_round_trip
|
|
562
|
+
res = get("/int_round_trip")
|
|
563
|
+
# libpq text format: the int comes back as the string "42".
|
|
564
|
+
assert_equal "val=42", res.body
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def test_insert_and_read_back
|
|
568
|
+
res = post("/insert", "body=insert-test&n=99")
|
|
569
|
+
assert_equal "200", res.code
|
|
570
|
+
id = res.body.split("=").last
|
|
571
|
+
assert id.to_i >= 1, "expected positive id, got #{id}"
|
|
572
|
+
|
|
573
|
+
res2 = get("/by_id/#{id}")
|
|
574
|
+
assert_match(/body=insert-test/, res2.body)
|
|
575
|
+
assert_match(/n=99/, res2.body)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def test_missing_table_error_path
|
|
579
|
+
res = get("/missing_table")
|
|
580
|
+
# exec now RAISES PG::UndefinedTable (rescued in the route).
|
|
581
|
+
assert_match(/raised=UndefinedTable/, res.body)
|
|
582
|
+
assert_match(/sqlstate=42P01/, res.body)
|
|
583
|
+
assert_match(/match42P01=yes/, res.body)
|
|
584
|
+
# the leaf is a PG::Error (base rescue + is_a? walk the hierarchy).
|
|
585
|
+
assert_match(/is_pg_error=yes/, res.body)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def test_unique_violation_reports_23505
|
|
589
|
+
res = get("/unique_violation")
|
|
590
|
+
# the duplicate INSERT raises PG::UniqueViolation; first succeeds.
|
|
591
|
+
assert_match(/first_ok=yes/, res.body)
|
|
592
|
+
assert_match(/second_raised=UniqueViolation/, res.body)
|
|
593
|
+
assert_match(/sqlstate=23505/, res.body)
|
|
594
|
+
assert_match(/is_pg_error=yes/, res.body)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def test_escape_literal_quotes_apostrophe
|
|
598
|
+
res = get("/escape_literal")
|
|
599
|
+
# libpq emits PostgreSQL's E'...' or '...' form; either should
|
|
600
|
+
# contain a doubled-up '' for the embedded apostrophe.
|
|
601
|
+
assert_match(/lit=.*'O''Brien'/, res.body)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def test_escape_identifier_quotes_name
|
|
605
|
+
res = get("/escape_identifier")
|
|
606
|
+
assert_equal "ident=\"users\"", res.body
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def test_transaction_commit_persists
|
|
610
|
+
res = get("/tx_commit")
|
|
611
|
+
assert_match(/^id=\d+ body=tx-commit-row\z/, res.body)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def test_transaction_rollback_does_not_persist
|
|
615
|
+
res = get("/tx_rollback")
|
|
616
|
+
assert_equal "after_rollback_count=0", res.body
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def test_cmd_status_for_update
|
|
620
|
+
res = get("/cmd_status")
|
|
621
|
+
# libpq cmd_status format: "UPDATE <rows_affected>".
|
|
622
|
+
assert_match(/^status=\[UPDATE \d+\] tuples=\d+\z/, res.body)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def test_many_results_concurrent
|
|
626
|
+
res = get("/many_results")
|
|
627
|
+
# 20 rows numbered 0..19; sum is 190.
|
|
628
|
+
assert_equal "sum=190", res.body
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# -------- PG::Pool tests --------
|
|
632
|
+
|
|
633
|
+
def test_pool_starts_healthy_with_full_free_list
|
|
634
|
+
res = get("/pool_size")
|
|
635
|
+
assert_equal "size=4 available=4 healthy=yes", res.body
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def test_pool_checkout_returns_usable_connection
|
|
639
|
+
res = get("/pool_query")
|
|
640
|
+
assert_equal "val=1/pool", res.body
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def test_pool_drains_and_refills
|
|
644
|
+
res = get("/pool_drain_refill")
|
|
645
|
+
# 2 checkouts -> drained=2; 2 checkins -> refilled=4 (back to size).
|
|
646
|
+
assert_equal "size=4 drained=2 refilled=4", res.body
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def test_pool_returned_connection_is_reusable
|
|
650
|
+
res = get("/pool_reusable")
|
|
651
|
+
assert_equal "first=1 second=2", res.body
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def test_pool_exhaustion_raises_pool_exhausted
|
|
655
|
+
res = get("/pool_exhaust")
|
|
656
|
+
assert_equal "200", res.code
|
|
657
|
+
# checkout past the timeout raises PG::PoolExhausted, caught both
|
|
658
|
+
# as the exact class and via the PG::Error parent (matz/spinel#1041).
|
|
659
|
+
assert_equal "exact=yes parent=yes", res.body
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# --- async exec ---
|
|
663
|
+
|
|
664
|
+
def test_async_exec_returns_same_result_as_sync
|
|
665
|
+
res = get("/async_exec")
|
|
666
|
+
assert_equal "val=async-hello", res.body
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def test_async_exec_params_round_trip
|
|
670
|
+
res = get("/async_params/6")
|
|
671
|
+
assert_equal "val=42", res.body
|
|
672
|
+
end
|
|
673
|
+
end
|