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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. 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