pgi 1.0.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00a4f48d4d9337019f30a191e60474cb75ff45a19c1653d0bf8a1897dd6551bf
4
- data.tar.gz: 2187c091a690e52b9d66efdbe7038932d6cc783aab8d95516246fe0ca564d48d
3
+ metadata.gz: e57eefff746743d0e70c5c256fa2fed74f2335c07b9657c67e0f517a5a37b927
4
+ data.tar.gz: '0073639115a44fe351077c3577b7e355da0165e580683c17620fc50b824303fe'
5
5
  SHA512:
6
- metadata.gz: 2cd4e11c2341f5151ba6b09549221e6345f0f9d1442c36d9631b14242815b95b470096524229e94296bdc35811f966eb5ff4a7250c1de442678d73cd498f81b6
7
- data.tar.gz: 267ba7b3dc84f507b6d47407cc77a42e6bc3a42fad1797c36494a90d672a2cf5a1dffed85ddf3953dc55666e00e37cdf8e08c9883fd1a8bce761a3b078897d6a
6
+ metadata.gz: 7b981f438da0b1f82946471622922d1eee510cb135b67f3db5fa108e24fdb2b7936648c55385841f13d67bfc1a479c48c78e6298701a4d962abc2f69e2fbb614
7
+ data.tar.gz: 38a762ec93398e240563e2dfc46d3b7ae8d8e3d1c08938c23c25e90ad2286789e4b1b4b553c7f0185d1146ea6c8acb7cf80ab8370678d36e43a9f753f4964c92
data/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## Unreleased
4
+
5
+ - **Sort on a projection** — `#order` and `#page`/`#keyset` accept a declared
6
+ `projections:` name as the sort column and order (and seek) on its
7
+ expression, so a list can page on a computed value.
8
+
9
+ - **`DB.configure` requires `pg_conn_uri`** — leaving it unset used to pass
10
+ `configure` and raise at the first pool checkout, naming `Connection.new`'s
11
+ keywords instead of the knob you set; it now raises `ArgumentError` there and
12
+ then. Pass `pg_conn_uri: ""` to ask libpq for its defaults on purpose.
13
+
14
+ - **`DB.configure` no longer shares state between databases** (bug fix) — the
15
+ options lived on the class and the pool read them at checkout time, so a second
16
+ `configure` silently took over the first DB's pool. Each call now keeps its
17
+ own options.
18
+
19
+ - **`Connection.new` requires a connection** — building one with neither
20
+ `conn:` nor `conn_uri:` now raises `ArgumentError` instead of connecting to
21
+ whatever libpq's environment defaults point at. Pass `conn_uri: ""` to ask
22
+ for those defaults on purpose.
23
+
24
+ - **A colliding projection name raises** (behaviour change) — a `projections:`
25
+ entry named after a base column landed as a second result field and silently
26
+ overwrote the base value; the read now raises, as an unaliased joined column
27
+ from `#select` already did.
28
+
29
+ - **Dependency updates**
30
+ - connection_pool: the dependency widens from `~> 2.4` to `>= 2.4, < 4`.
31
+
32
+ ## 1.1.0 (2026-08-28)
33
+
34
+ The query release: joins, search, projected columns — and server notices that
35
+ respect your logger.
36
+
37
+ - **Joins** — `#join(table, on:)` / `#page(joins:)` add INNER JOINs for
38
+ filtering and sorting on combined rows; result rows stay the base table's,
39
+ so model mapping is unchanged.
40
+
41
+ ```ruby
42
+ Repository.join(:memberships, on: { id: :member_id })
43
+ .where(memberships: { accepted: true }).all
44
+ ```
45
+
46
+ `#where` and `#order` take qualified columns (`{ users: :name }`), keyset
47
+ pagination works over a joined sort column (FK -> PK cardinality), and an
48
+ `on:` key may itself be qualified to chain a second hop off an earlier join
49
+ (declare joins in dependency order).
50
+
51
+ - **Search** — `#search(columns, terms)` / `#page(search:)`: case-insensitive
52
+ substring search. Every term must hit in some column — the behaviour of a
53
+ search box that narrows as words are added.
54
+
55
+ ```ruby
56
+ Repository.search([:name, :email], %w[john smith]).all
57
+ ```
58
+
59
+ LIKE metacharacters are escaped, columns may be join-qualified, and it only
60
+ adds a predicate, so it composes with keyset pagination.
61
+
62
+ - **Projecting joined columns** — `#select({ table => column })` appends a
63
+ joined table's column to the result alongside `"base".*` (`#join` alone
64
+ stays filter/sort-only). Alias form `{ table => { column => alias } }`;
65
+ rows come back as raw hashes. A projected column colliding with a base
66
+ column raises when the rows come back — alias to disambiguate.
67
+
68
+ - **Projections** — a declared catalog of computed columns, opted into per
69
+ read; never evaluated unless asked, so cost stays a visible per-read
70
+ decision.
71
+
72
+ ```ruby
73
+ extend PGI::Dataset[DB, :teams,
74
+ projections: { mates_count: "SELECT COUNT(*) FROM teammates WHERE team_id = teams.id" }]
75
+
76
+ TeamRepository.page(nil, 25, project: [:mates_count])
77
+ ```
78
+
79
+ Unknown names raise at `#project`; `#count` ignores projections; writes shed
80
+ projected attribute keys so model round-trips just work.
81
+
82
+ - **Collation** — `#order(:name, :asc, collate: "da-x-icu")` and
83
+ `#page(..., collate:)` sort text in a named (e.g. ICU) collation, so Danish
84
+ `æ ø å` file after `z` regardless of the database default.
85
+
86
+ - **Server notices route to the configured logger** instead of libpq's stderr,
87
+ on both constructor doors (`conn_uri:` and `conn:`). Severity is preserved:
88
+ `RAISE WARNING` logs at `warn`, everything else at `debug` — a logger at
89
+ INFO stays quiet through chatter but still surfaces warnings.
90
+
3
91
  ## 1.0.0
4
92
 
5
93
  First public release.
data/README.md CHANGED
@@ -1,15 +1,18 @@
1
1
  # PGI
2
2
 
3
3
  PGI is a simple and convenient interface for PostgreSQL with a few enhancements.
4
+ It gives you pooled, self-healing connections (`PGI::DB`), a super lightweight
5
+ repository toolkit (`PGI::Dataset`), and plain SQL migrations
6
+ (`PGI::SchemaMigrator`) — and nothing else. No ActiveRecord, no DSL to learn on
7
+ top of SQL you already know.
4
8
 
5
9
  ## PGI::DB
6
10
 
7
- The `PGI::DB` handles connections to a PostgreSQL databases. It features...
11
+ `PGI::DB` handles connections to a PostgreSQL database. It features:
8
12
 
9
- * Connection Pool
10
- * Connection auto-healing capabilities
11
-
12
- Usage:
13
+ * a connection pool
14
+ * connection auto-healing: lost connections and pool checkout timeouts share a
15
+ retry budget, so a database restart is a pause, not a crash
13
16
 
14
17
  ```ruby
15
18
  DB = PGI::DB.configure do |options|
@@ -25,35 +28,59 @@ end
25
28
  DB.exec_stmt("my_stmt", "SELECT 1+1")
26
29
  ```
27
30
 
28
- ## PGI::Dataset
31
+ ### Server notices go to your logger
29
32
 
30
- The `PGI::Dataset` is a super light weight ActiveRecord::Relation replacement. It delivers a clean and simple querying interface:
33
+ Anything the server says on the side — a `RAISE NOTICE`, a `DROP CASCADE`'s
34
+ chatter — goes to the configured logger instead of libpq's stderr default,
35
+ and keeps its severity: a `RAISE WARNING` logs at `warn`, everything else at
36
+ `debug`. A logger running at INFO stays quiet through routine chatter but
37
+ still shows you warnings.
31
38
 
32
- * `#select(column1, ...)` allows you to limit the result set to only contain specified columns
33
- * `#where(...)` - can only be called once per query, so combine all conditions in a single call. Two forms:
34
- * `#where("name = ? AND age > ?", ['joe', 21])` - a string clause with placeholders (`?` or `$1`)
35
- * `#where(name: 'joe')` - as a Hash (multiple keys are concatenated with an ' AND ')
36
- * `#order(:column, <:asc|:desc>)` - sort result set by column and direction, can be invoked multiple times
37
- * `#limit(<num>)` - limits the result set to the specified number of records
38
- * `#first` - get the first record in a set
39
- * `#all`- get an array of records
40
- * `#count`- get the number of rows in a table
41
- * `#page(cursor, size, sort_by, sort_dir)` - keyset pagination; pass `nil` for the first page, then the **id of the last row** as the cursor for each subsequent page
39
+ ```ruby
40
+ DB.exec_stmt("noisy", "DO $$ BEGIN RAISE WARNING 'heads up'; END $$")
41
+ # => logger.warn("heads up")
42
+ ```
43
+
44
+ ## PGI::Dataset
45
+
46
+ `PGI::Dataset` is a super lightweight `ActiveRecord::Relation` replacement.
47
+ Extend a repository class with it and you get a clean querying interface:
42
48
 
43
49
  ```ruby
44
50
  class Repository
45
51
  extend PGI::Dataset[DB, :members, scope: "deleted_at IS NULL"]
46
52
  end
47
- ```
48
53
 
49
- ```sql
50
- -- Each column used as sort_by in page() needs a composite index on (column, id).
51
- -- Two separate single-column indexes are not sufficient — Postgres needs
52
- -- the combined (column, id) ordering to seek directly to the cursor position.
53
- CREATE INDEX ON members (name ASC, id ASC);
54
- CREATE INDEX ON members (created_at ASC, id ASC);
54
+ Repository.find(id) # one row by id
55
+ Repository.where(name: "joe").all # rows matching a condition
56
+ Repository.page(nil, 20, :name, :asc) # first page of 20, sorted by name
55
57
  ```
56
58
 
59
+ The pieces:
60
+
61
+ * `Dataset#select(column1, ...)` — start a query limited to the specified
62
+ columns of the base table
63
+ * `Query#select(column1, ...)` — append a **joined** table's column onto the
64
+ base table's columns, see [Projecting joined columns](#projecting-joined-columns)
65
+ * `#where(...)` — can only be called once per query, so combine all conditions
66
+ in a single call. Two forms:
67
+ * `#where("name = ? AND age > ?", ['joe', 21])` — a string clause with placeholders (`?` or `$1`)
68
+ * `#where(name: 'joe')` — as a Hash (multiple keys are AND'ed together)
69
+ * `#order(:column, <:asc|:desc>)` — sort by column and direction, can be invoked multiple times
70
+ * `#limit(<num>)` — cap the number of rows
71
+ * `#first` / `#all` — one row / all rows
72
+ * `#count` — number of rows
73
+ * `#page(cursor, size, sort_by, sort_dir)` — keyset pagination (see below)
74
+ * `#join(table, on:)` — INNER JOIN for filtering and sorting ([Joins](#joins))
75
+ * `#search(columns, terms)` — substring search across columns ([Search](#search))
76
+ * `#project(*names)` — opt into declared computed columns ([Projections](#projections--computed-columns-you-opt-into))
77
+
78
+ ### Keyset pagination
79
+
80
+ `#page` fetches rows at constant cost no matter how deep you page. The cursor
81
+ is always the **id of the last row** from the previous page; pass `nil` for the
82
+ first page.
83
+
57
84
  ```ruby
58
85
  # First page — sorted by name
59
86
  page1 = Repository.page(nil, 20, :name, :asc)
@@ -62,6 +89,15 @@ page1 = Repository.page(nil, 20, :name, :asc)
62
89
  page2 = Repository.page(page1.last["id"], 20, :name, :asc)
63
90
  ```
64
91
 
92
+ Each column used as `sort_by` needs a **composite** index on `(column, id)` —
93
+ two separate single-column indexes are not sufficient, because Postgres needs
94
+ the combined ordering to seek directly to the cursor position:
95
+
96
+ ```sql
97
+ CREATE INDEX ON members (name ASC, id ASC);
98
+ CREATE INDEX ON members (created_at ASC, id ASC);
99
+ ```
100
+
65
101
  Generated SQL for page 2 (`sort_by != :id`):
66
102
  ```sql
67
103
  SELECT * FROM members
@@ -80,34 +116,217 @@ ORDER BY id ASC
80
116
  LIMIT 20
81
117
  ```
82
118
 
83
- ### How keyset pagination works
119
+ How it works:
120
+
121
+ - **Sorting by id** — `WHERE id > $cursor ORDER BY id`. Simple seek on the
122
+ primary key index.
123
+ - **Sorting by another column** — the composite subquery cursor above keeps
124
+ pages globally sorted: Postgres resolves the subquery via the primary-key
125
+ index (one fast lookup), then uses the composite `(sort_col, id)` index to
126
+ seek to that exact position and scan forward.
127
+ - `LIMIT/OFFSET` scans and discards all prior rows on every page — cost grows
128
+ with depth. Keyset pagination does not.
129
+
130
+ ### Collation — locale-aware text ordering
131
+
132
+ Text sorts in whatever collation you name, per read. Danish files
133
+ `æ ø å` after `z`; the database's default (often `en_US`) files them wrong.
134
+
135
+ ```ruby
136
+ Repository.order(:name, :asc, collate: "da-x-icu").all
137
+ Repository.page(cursor, 20, :name, :asc, collate: "da-x-icu")
138
+ ```
139
+
140
+ The named collation must exist in the database (Postgres ships ICU collations
141
+ like `da-x-icu`; `und-x-icu` is a sane universal order). For `#page`, the
142
+ composite index should be built with the same collation or Postgres falls back
143
+ to sorting.
144
+
145
+ ### Joins
146
+
147
+ `#join(table, on:)` adds an `INNER JOIN` so `#where` and `#order` (and keyset
148
+ pagination) can reference the joined table's columns. Joins are for **filtering
149
+ and sorting only, not projection** — the select list stays the base table's
150
+ columns, so result rows still map to the base model unchanged.
151
+
152
+ ```ruby
153
+ # Members that have an accepted membership in some account.
154
+ # `on:` maps base-table column => joined-table column.
155
+ Repository
156
+ .join(:memberships, on: { id: :member_id })
157
+ .where(memberships: { accepted: true })
158
+ .all
159
+ ```
160
+
161
+ ```sql
162
+ SELECT "members".* FROM members
163
+ INNER JOIN "memberships" ON "members"."id" = "memberships"."member_id"
164
+ WHERE "memberships"."accepted" = true
165
+ ```
84
166
 
85
- `#page` fetches rows at constant cost regardless of page depth. The cursor is always the scalar `id` of the last row from the previous page.
167
+ A Hash value under a table-name key (`memberships: { accepted: true }`)
168
+ qualifies its columns with that table — the key must be the base table or an
169
+ already-joined table, never guessed. `#order` takes the same
170
+ `{ table => column }` form to sort by a joined column.
86
171
 
87
- - **Sorting by id** — `WHERE id > $cursor ORDER BY id`. Simple seek on the primary key index.
88
- - **Sorting by another column** — generates a composite subquery cursor so pages are globally sorted:
89
- ```sql
90
- WHERE (sort_col, id) > (SELECT sort_col, id FROM table WHERE id = $cursor)
91
- ORDER BY sort_col, id
172
+ `#join` returns a `Query` yielding **raw row hashes** (like `#where`); model
173
+ mapping is reserved for `Dataset` methods. For a paginated, model-mapped joined
174
+ read, pass the `joins:` keyword to `#page`:
175
+
176
+ ```ruby
177
+ # Page members sorted by their user's name.
178
+ Repository.page(cursor, 20, { users: :name }, :asc,
179
+ joins: { memberships: { id: :member_id },
180
+ users: { { memberships: :user_id } => :id } })
181
+ ```
182
+
183
+ `joins:` maps *joined table => on-mapping*. An on-key may itself be qualified
184
+ (`{ { memberships: :user_id } => :id }`) to chain a **second hop** off an
185
+ earlier join — join `memberships` to the base, then join `users` to
186
+ `memberships`. The referenced table must already be joined, so **declare joins
187
+ in dependency order** (an ordered Hash preserves it).
188
+
189
+ Notes:
190
+
191
+ - Keyset pagination over a joined sort column requires **at most one joined row
192
+ per base row** (e.g. `FK -> PK`); with 1:N joins the page boundaries are
193
+ ill-defined and the cursor lookup fails.
194
+ - A `scope:` with unqualified columns becomes ambiguous once a join shares a
195
+ column name — qualify the scope's columns (e.g. `members.deleted_at IS NULL`).
196
+
197
+ ### Projecting joined columns
198
+
199
+ `#join` is filter/sort-only — the projection stays `"base".*`. To also
200
+ **return** a joined table's column, append it with `#select`:
201
+
202
+ ```ruby
203
+ # Teams the base row belongs to, plus the roster row's `owner` flag.
204
+ Repository.join(:teammates, on: { id: :team_id })
205
+ .join(:memberships, on: { { teammates: :membership_id } => :id })
206
+ .select({ teammates: :owner })
207
+ .where(memberships: { user_id: user_id })
208
+ .to_a
209
+ # SELECT "teams".*, "teammates"."owner" FROM teams INNER JOIN ...
210
+ ```
211
+
212
+ `#select` takes the same grammar as `#join`/`#where`: a bare column (qualified
213
+ with the base table) or a `{ table => column }` pair. An alias form
214
+ `{ table => { column => alias } }` renders `AS "alias"`.
215
+
216
+ Because a projected joined column is by definition absent from the base model's
217
+ schema, a `Query` carrying `#select` yields **raw row hashes only** — it never
218
+ threads through `#page`/`#all` model mapping. `"base".*` is opaque (pgi has no
219
+ schema introspection), so a joined column that shares a base column's name
220
+ cannot be caught up front; it surfaces as a duplicate result field and `#to_a`/
221
+ `#first`/`#each` **raise**. Pre-empt it with the alias form.
222
+
223
+ ### Projections — computed columns you opt into
224
+
225
+ `projections:` declares named computed columns when the dataset is extended —
226
+ raw SQL you author, like `scope:`, never request data. Declaring costs
227
+ nothing: a projection is only evaluated when a read **asks for it**, so the
228
+ cost of a computed column stays a visible, per-read decision.
229
+
230
+ ```ruby
231
+ class TeamRepository
232
+ extend PGI::Dataset[DB, :teams,
233
+ scope: "deleted_at IS NULL",
234
+ projections: { mates_count: "SELECT COUNT(*) FROM teammates WHERE team_id = teams.id" }]
235
+ end
236
+
237
+ TeamRepository.page(nil, 25, project: [:mates_count]) # the list pays for what it shows
238
+ TeamRepository.project(:mates_count).where(id: id).first # chain form (raw rows)
239
+ TeamRepository.find(id) # pays nothing
240
+ ```
241
+
242
+ Notes:
243
+
244
+ - **Opt-in, never ambient** — an unprojected read carries no projection keys
245
+ and pays no projection cost. Opting into an undeclared name raises.
246
+ `#count` ignores projections (an aggregate has no row to enrich).
247
+ - **Writes shed projection keys** — a model round-trip (find → to_h → update)
248
+ may carry projected attributes; INSERT/UPDATE drop them silently. Write
249
+ RETURNING never projects: presence of a projected key means "this read chose
250
+ to know".
251
+ - **Cost when opted in**: evaluated per *output* row — after WHERE/LIMIT — so
252
+ a paginated read pays `page_size × subquery`. With an indexed correlate
253
+ (e.g. `teammates(team_id)`) that is an index-only probe per row. Sorting or
254
+ filtering **on** a projection promotes evaluation to the whole scope; that
255
+ EXPLAIN is the caller's to own. If a projection ever measures hot, the
256
+ escalation is a trigger-maintained column, not a cleverer query.
257
+ - **Sort on a projection** — `#order` and `#page`/`#keyset` take a declared
258
+ projection's name as the sort column and order on its expression; the
259
+ read need not project it. The cursor row resolves the same expression.
260
+
261
+ ```ruby
262
+ Repository.page(nil, 20, :mates_count, :desc)
92
263
  ```
93
- Postgres resolves the subquery via the primary-key index (a single fast lookup), then uses the composite `(sort_col, id)` index to seek directly to that position and scan forward. The two separate single-column indexes are not equivalent — a composite B-tree index is required so that Postgres can seek to an exact `(sort_col, id)` position rather than scanning and filtering.
264
+ - **A colliding name raises** — a projection named after a base column would
265
+ overwrite it in the row hash, so the read raises when the rows come back
266
+ (same guard as an unaliased joined column). Pick names that cannot collide
267
+ (`mates_count`, not `count`).
94
268
 
95
- `LIMIT/OFFSET` scans and discards all prior rows on every page request — cost grows linearly with depth. Keyset pagination does not.
269
+ ### Search
270
+
271
+ `#search(columns, terms)` adds a case-insensitive substring search and AND's it
272
+ into the WHERE clause. Each term becomes an OR-group of `ILIKE` matches across
273
+ every column — a term hits when **any** column contains it, and a row matches
274
+ only when **every** term hits somewhere. That is the behaviour of a search box
275
+ that narrows as words are added: "john smith" finds the row named
276
+ "Smith, John". Tokenising the query string is the caller's job; pass the terms
277
+ as an array.
278
+
279
+ ```ruby
280
+ # Chain form:
281
+ Repository.search([:name, :email], %w[john smith]).all
282
+
283
+ # Paginated, across a join:
284
+ Repository.page(cursor, 20, :name, :asc,
285
+ search: { columns: [{ users: :name }, { users: :email }],
286
+ terms: %w[john smith] },
287
+ joins: { users: { user_id: :id } })
288
+ ```
289
+
290
+ LIKE metacharacters (`% _ \`) in a term are escaped so they match literally;
291
+ blank terms are dropped. Columns take the same `{ table => column }` grammar as
292
+ `#where`, so a search spans the same joins. It only adds a predicate, so it is
293
+ **keyset-compatible** — the cursor stays on the sort column.
96
294
 
97
295
  ### Constraints
98
296
 
99
- - **`sort_by` columns must be `NOT NULL`.** SQL row comparison with NULL yields NULL, so rows with a NULL sort value are silently excluded from every cursor page — and if the anchor row itself has a NULL sort value, the next page comes back empty mid-stream.
100
- - **Hard-deleting an anchor row ends that pagination sequence.** The anchor lookup is by primary key; if the row is gone, the next page is empty and indistinguishable from the end of the result set. Soft deletion via a `scope:` (e.g. `deleted_at IS NULL`) is safe — the anchor lookup deliberately bypasses the scope, so a row that left the scope between pages still anchors correctly.
101
- - **Every table is expected to have a unique, totally ordered `id`** (SERIAL, UUIDv7, ...) — it is the tie-breaker that makes pages deterministic, and the whole `Dataset` interface assumes it.
297
+ - **`sort_by` columns must be `NOT NULL`.** SQL row comparison with NULL yields
298
+ NULL, so rows with a NULL sort value are silently excluded from every cursor
299
+ page — and if the anchor row itself has a NULL sort value, the next page
300
+ comes back empty mid-stream.
301
+ - **Hard-deleting an anchor row ends that pagination sequence.** The anchor
302
+ lookup is by primary key; if the row is gone, the next page is empty and
303
+ indistinguishable from the end of the result set. Soft deletion via a
304
+ `scope:` (e.g. `deleted_at IS NULL`) is safe — the anchor lookup deliberately
305
+ bypasses the scope, so a row that left the scope between pages still anchors
306
+ correctly.
307
+ - **Every table is expected to have a unique, totally ordered `id`** (SERIAL,
308
+ UUIDv7, ...) — it is the tie-breaker that makes pages deterministic, and the
309
+ whole `Dataset` interface assumes it.
310
+
311
+ ## PGI::SchemaMigrator
312
+
313
+ Plain up/down SQL migrations tracked in a `schema_migrations` table — no DSL,
314
+ the migration *is* the SQL.
102
315
 
103
- ## Documentation
316
+ ## Development
104
317
 
105
318
  Dependencies:
106
319
 
107
320
  * https://github.com/ged/ruby-pg
108
321
  * https://github.com/mperham/connection_pool
109
322
 
110
- Create developer/test DB:
323
+ Run the test suite (rubocop + specs, against a containerized Postgres):
324
+
325
+ ```
326
+ podman compose run --rm ruby
327
+ ```
328
+
329
+ Or create a developer/test DB by hand:
111
330
 
112
331
  ```
113
332
  sudo su - postgres
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.2.0
@@ -10,9 +10,12 @@ module PGI
10
10
 
11
11
  # Create instance
12
12
  #
13
- # @param pool [ConnectionPool]
14
13
  # @param logger [Logger]
14
+ # @param conn_uri [String] connection URI; "" asks libpq for its own defaults
15
+ # @param conn [PG::Connection] an already built connection, used as is
15
16
  def initialize(logger:, conn_uri: nil, conn: nil)
17
+ raise ArgumentError, "conn or conn_uri required" unless conn || conn_uri
18
+
16
19
  @logger = logger
17
20
 
18
21
  @conn = conn || PG::Connection.new(conn_uri).tap do |new_conn|
@@ -24,7 +27,19 @@ module PGI
24
27
 
25
28
  new_conn.type_map_for_results = PG::BasicTypeMapForResults.new(new_conn, registry: regi)
26
29
  new_conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(new_conn, registry: regi)
27
- end || raise("no connection provided")
30
+ end
31
+
32
+ # Server notices (a DROP CASCADE's chatter, a RAISE NOTICE) route to
33
+ # the configured logger instead of libpq's stderr default: a library
34
+ # that accepts a logger must not print around it - whichever door the
35
+ # connection came through. Severity survives the trip, so a RAISE
36
+ # WARNING still reaches a consumer whose logger runs at INFO.
37
+ @conn.set_notice_receiver do |result|
38
+ severity = result.result_error_field(PG::PG_DIAG_SEVERITY_NONLOCALIZED) ||
39
+ result.result_error_field(PG::PG_DIAG_SEVERITY)
40
+ message = result.result_error_field(PG::PG_DIAG_MESSAGE_PRIMARY) || result.error_message.strip
41
+ severity == "WARNING" ? @logger&.warn(message) : @logger&.debug(message)
42
+ end
28
43
  end
29
44
 
30
45
  # Execute a prepared statement. Statements are auto-created with fallback to exec_params
@@ -11,6 +11,9 @@ module PGI
11
11
  # @param command [String] the command part of the query (default: `SELECT * FROM <table>`)
12
12
  # @param options [Hash] hash of options: scope, where, params, limit, order, returning
13
13
  # @return [Query] new instance of Query
14
+ TABLE_NAME = /\A[a-z_][a-z0-9_]*\z/
15
+ COLLATION_NAME = /\A[A-Za-z0-9_-]+\z/
16
+
14
17
  def initialize(database, table, command, **options)
15
18
  @database = database
16
19
  @table = table
@@ -21,6 +24,95 @@ module PGI
21
24
  @order = options.fetch(:order, {})
22
25
  @limit = options.fetch(:limit, 10)
23
26
  @returning = options.fetch(:returning, nil)
27
+ # Declared computed columns (see Dataset projections:) - the catalog
28
+ # a read may opt into via #project. Never evaluated unless asked:
29
+ # cost is a visible, per-read decision.
30
+ @projections = options.fetch(:projections, {})
31
+ @projected = []
32
+ @joins = []
33
+ @join_tables = []
34
+ @select = []
35
+ end
36
+
37
+ # Opt into declared projections for THIS read: the named computed
38
+ # columns join the select list as (expr) AS name. Names must be
39
+ # declared on the dataset (the trust boundary stays at extension
40
+ # time); unknown names raise. A name that collides with a base column
41
+ # raises when the rows come back, rather than clobbering it.
42
+ #
43
+ # @param names [Array<Symbol>] declared projection names
44
+ # @return [Query] the Query instance (for method chaining)
45
+ def project(*names)
46
+ unknown = names.map(&:to_sym) - @projections.keys.map(&:to_sym)
47
+ raise "Unknown projection(s): #{unknown.inspect}" unless unknown.empty?
48
+
49
+ @projected |= names.map(&:to_sym)
50
+ self
51
+ end
52
+
53
+ # Adds an INNER JOIN so WHERE/ORDER BY/keyset can reference the joined
54
+ # table's columns. Joins are for filtering and sorting, not projection:
55
+ # the select list stays the base table's columns, so result rows map to
56
+ # the base model unchanged. Identifiers only - never parameterized.
57
+ #
58
+ # An on-mapping key is a bare column (qualified with the base table) or a
59
+ # { table => column } pair that qualifies it with a previously joined
60
+ # table - the same grammar #order/#where accept. That is what enables a
61
+ # second hop: join A to the base, then join B to A. The referenced table
62
+ # must already be joined, so declare joins in dependency order.
63
+ #
64
+ # Notes:
65
+ # - a Dataset :scope with unqualified columns becomes ambiguous once a
66
+ # join shares a column name - qualify scope columns to combine them
67
+ # - keyset pagination over a joined sort column requires at most one
68
+ # joined row per base row (e.g. FK -> PK); with 1:N joins page
69
+ # boundaries are ill-defined and the cursor lookup will fail
70
+ #
71
+ # @param table [Symbol] the table to join
72
+ # @param on [Hash] key column(s) => joined-table column(s); a key may be a
73
+ # bare base-table column or a { table => column } pair naming the base
74
+ # table or an already-joined table
75
+ # @raise [RuntimeError] if the table name or on-mapping is invalid
76
+ # @return [Query] return the Query instance (for method chaining)
77
+ def join(table, on:, type: :inner)
78
+ raise "Invalid JOIN table: #{table.inspect}" unless table.to_s.match?(TABLE_NAME)
79
+ raise "JOIN on: must map base column(s) to joined column(s)" unless on.is_a?(Hash) && !on.empty?
80
+ raise "Invalid JOIN type: #{type.inspect}" unless %i[inner left].include?(type)
81
+
82
+ conditions = on.map do |base_col, joined_col|
83
+ "#{qualified_column(base_col)} = #{Utils.sanitize_column(joined_col, table)}"
84
+ end.join(" AND ")
85
+
86
+ @join_tables << table.to_sym
87
+ @joins << %(#{type == :left ? "LEFT" : "INNER"} JOIN "#{table}" ON #{conditions})
88
+ self
89
+ end
90
+
91
+ # Appends columns to the projection so a joined read can return a joined
92
+ # table's columns alongside the base table's `"base".*` - the one thing
93
+ # #join deliberately does not do (it is filter/sort only). Because the
94
+ # projected column is by definition absent from the base model's schema,
95
+ # a Query with #select yields RAW row hashes only; it never threads
96
+ # through #page/#all model mapping. Identifiers only - never parameterized.
97
+ #
98
+ # Columns take the same grammar as #join/#where/#order plus an alias form:
99
+ # - a bare column -> qualified with the base table
100
+ # - a { table => column } pair -> qualified with the base or a joined
101
+ # table (which must already be joined)
102
+ # - a { table => { column => alias } } pair -> the above, AS "alias"
103
+ #
104
+ # "base".* is opaque (pgi has no schema introspection), so a joined column
105
+ # sharing a base column's name cannot be caught here - it surfaces as a
106
+ # duplicate field name when the rows come back, where #first/#to_a/#each
107
+ # RAISE rather than silently clobber. Pre-empt it with the alias form.
108
+ #
109
+ # @param columns [Array<Symbol, Hash>] columns to append to the projection
110
+ # @raise [RuntimeError] if a column reference or alias mapping is malformed
111
+ # or names an unknown table
112
+ # @return [Query] return the Query instance (for method chaining)
113
+ def select(*columns)
114
+ columns.each { |column| @select << projected_column(column) }
115
+ self
24
116
  end
25
117
 
26
118
  # Adds a WHERE clause to the query - can only be called once per query,
@@ -38,12 +130,28 @@ module PGI
38
130
 
39
131
  case clause
40
132
  when Hash
41
- clause = clause.map do |k, v|
42
- @params << v
43
- "#{Utils.sanitize_columns(k, @table).first} = $#{@params.size}"
133
+ # A Hash value under a table-name key qualifies its columns with that
134
+ # table: where(account_id: 1, users: { name: "x" }). The key must be
135
+ # the base table or a joined table - never guessed - which fences the
136
+ # namespace for possible future non-table Hash semantics (e.g. JSONB).
137
+ clause = clause.flat_map do |k, v|
138
+ if v.is_a?(Hash)
139
+ assert_known_table!(k)
140
+ v.map do |col, val|
141
+ @params << val
142
+ "#{Utils.sanitize_column(col, k)} = $#{@params.size}"
143
+ end
144
+ else
145
+ @params << v
146
+ ["#{Utils.sanitize_column(k, @table)} = $#{@params.size}"]
147
+ end
44
148
  end.join(" AND ")
45
149
  when String
46
- raise "Use placeholders in WHERE clause" if clause =~ /=(?!\s*[?$])/
150
+ # The guard lints against inlined VALUES - quoted strings and bare
151
+ # numbers, the injection surface. Identifiers (join conditions,
152
+ # subqueries), keywords (true/NULL) and constructs like = ANY($n)
153
+ # are legitimate parameterized SQL and pass (issue #19).
154
+ raise "Use placeholders in WHERE clause" if clause =~ /[=<>]\s*-?\s*['0-9]/
47
155
 
48
156
  offset = @params.size
49
157
  @params += params
@@ -57,16 +165,54 @@ module PGI
57
165
  self
58
166
  end
59
167
 
168
+ # Adds a case-insensitive substring search and AND's it into the WHERE
169
+ # clause. Each term becomes an OR-group of ILIKE matches across every
170
+ # given column; the groups are AND'ed together. So a term hits when ANY
171
+ # column contains it, and a row matches only when EVERY term hits
172
+ # somewhere - the shape of a search box that narrows as words are added.
173
+ # Each term binds a single %term% parameter, shared by its OR-group.
174
+ #
175
+ # LIKE metacharacters (% _ \) in a term are escaped so they match
176
+ # literally. Blank terms are dropped; empty columns or terms are a no-op.
177
+ # Like #keyset, this combines with an existing WHERE (call it after
178
+ # #where), so it does not raise the "already set" guard.
179
+ #
180
+ # Columns take the same grammar as #where/#order - a bare column
181
+ # (qualified with the base table) or a { table => column } pair naming the
182
+ # base table or an already-joined table - so a search can span joins.
183
+ #
184
+ # @param columns [Array] the text columns to match against
185
+ # @param terms [Array<String>] search terms, already tokenised by the caller
186
+ # @return [Query] return the Query instance (for method chaining)
187
+ def search(columns, terms)
188
+ columns = Array(columns)
189
+ terms = Array(terms).reject { |t| t.to_s.strip.empty? }
190
+ return self if columns.empty? || terms.empty?
191
+
192
+ groups = terms.map do |term|
193
+ @params << "%#{escape_like(term)}%"
194
+ placeholder = "$#{@params.size}"
195
+ "(#{columns.map { |col| "#{qualified_column(col)} ILIKE #{placeholder}" }.join(" OR ")})"
196
+ end.join(" AND ")
197
+
198
+ @where = @where ? "#{groups} AND (#{@where})" : groups
199
+ self
200
+ end
201
+
60
202
  # Adds a ORDER BY clause to the query - suports multiple calls to the method
61
203
  #
62
- # @param column [Symbol] the columns
204
+ # @param column [Symbol, Hash] the column - a single-pair Hash qualifies
205
+ # it with a joined table: { users: :name }; a declared projection's
206
+ # name sorts on its expression
63
207
  # @param direction [Symbol] the direction the sort should take - can be either `:desc` or `:asc`
208
+ # @param collate [String, nil] collation for text ordering, e.g. "da-x-icu"
209
+ # (policy - which locale maps to which collation - belongs to the caller)
64
210
  # @raise [RuntimeError] if the direction param is invalid
65
211
  # @return [Query] return the Query instance (for method chaining)
66
- def order(column, direction = :asc)
212
+ def order(column, direction = :asc, collate: nil)
67
213
  raise "Invalid ORDER BY direction: #{direction.inspect}" unless %i[asc desc].include?(direction)
68
214
 
69
- @order[Utils.sanitize_columns(column, @table)] = direction.to_s.upcase
215
+ @order[[collated_column(column, collate)]] = direction.to_s.upcase
70
216
  self
71
217
  end
72
218
 
@@ -90,25 +236,33 @@ module PGI
90
236
  # Do not combine with a conflicting #order call — pages are only correct when
91
237
  # the leading sort columns match the cursor predicate.
92
238
  #
93
- # @param sort_by [Symbol] the sort column
239
+ # @param sort_by [Symbol] the sort column, or a declared projection's name
94
240
  # @param cursor_id [*, nil] id of the last row from the previous page, or nil for the first page
95
241
  # @param sort_dir [Symbol] :asc or :desc
242
+ # @param collate [String, nil] collation for the sort column. Must be the
243
+ # same everywhere the column orders or compares - ORDER BY, the cursor
244
+ # tuple and the cursor subselect all carry it, or page boundaries drift
96
245
  # @return [Query] return the Query instance (for method chaining)
97
- def keyset(sort_by, cursor_id, sort_dir)
98
- order(sort_by, sort_dir)
99
- order(:id, sort_dir) unless sort_by.to_sym == :id
246
+ def keyset(sort_by, cursor_id, sort_dir, collate: nil)
247
+ sort_on_id = !sort_by.is_a?(Hash) && sort_by.to_sym == :id
248
+ order(sort_by, sort_dir, collate: (collate unless sort_on_id))
249
+ order(:id, sort_dir) unless sort_on_id
100
250
  return self unless cursor_id
101
251
 
102
252
  op = sort_dir == :asc ? ">" : "<"
103
- id_col = Utils.sanitize_columns(:id, @table).first
253
+ id_col = Utils.sanitize_column(:id, @table)
104
254
  @params << cursor_id
105
255
 
106
256
  clause =
107
- if sort_by.to_sym == :id
257
+ if sort_on_id
108
258
  "#{id_col} #{op} $#{@params.size}"
109
259
  else
110
- sort_col = Utils.sanitize_columns(sort_by, @table).first
111
- "(#{sort_col}, #{id_col}) #{op} (SELECT #{sort_col}, #{id_col} FROM #{@table} WHERE #{id_col} = $#{@params.size})"
260
+ # The cursor row's sort value must be resolved through the same
261
+ # FROM (incl. joins) as the outer query, or a joined sort column
262
+ # would not exist in the subselect.
263
+ sort_col = collated_column(sort_by, collate)
264
+ from = ["FROM #{@table}", *@joins].join(" ")
265
+ "(#{sort_col}, #{id_col}) #{op} (SELECT #{sort_col}, #{id_col} #{from} WHERE #{id_col} = $#{@params.size})"
112
266
  end
113
267
 
114
268
  @where = @where ? "#{clause} AND (#{@where})" : clause
@@ -124,6 +278,23 @@ module PGI
124
278
  scope << " AND " if scope && @where
125
279
 
126
280
  command = @command.dup
281
+ if command.start_with?("SELECT") && (@joins.any? || @select.any?)
282
+ # Joins are filter/sort-only: qualify the default star so joined
283
+ # columns never leak into result rows (and never collide). #select
284
+ # then appends its explicitly-projected joined columns onto it.
285
+ extra = @select.empty? ? "" : ", #{@select.join(", ")}"
286
+ if command == "SELECT * FROM #{@table}"
287
+ command = %(SELECT "#{@table}".*#{extra} FROM #{@table})
288
+ elsif @select.any?
289
+ command = command.sub(" FROM #{@table}", "#{extra} FROM #{@table}")
290
+ end
291
+ command << " #{@joins.join(" ")}" if @joins.any?
292
+ end
293
+ if @projected.any? && command.start_with?("SELECT") && command.include?(" FROM #{@table}")
294
+ # Additive: base columns plus the OPTED-IN computed columns
295
+ fragments = Utils.projection_fragments(@projections.slice(*@projected)).join(", ")
296
+ command = command.sub(" FROM #{@table}", ", #{fragments} FROM #{@table}")
297
+ end
127
298
  command << " WHERE #{scope}#{@where}" if @where || scope
128
299
  command << " ORDER BY #{Array(@order).map { |x| x.join(" ") }.join(", ")}" unless @order.empty?
129
300
  command << " LIMIT #{@limit}" if @limit
@@ -141,25 +312,19 @@ module PGI
141
312
  # @return [Hash]
142
313
  def first
143
314
  limit(1)
144
- @database
145
- .exec_stmt(Utils.stmt_name(@table, sql), sql, params)
146
- .first
315
+ result.first
147
316
  end
148
317
 
149
318
  # Get all the records in a result set
150
319
  #
151
320
  # @return [Array] Array of records as Hashes
152
321
  def to_a
153
- @database
154
- .exec_stmt(Utils.stmt_name(@table, sql), sql, params)
155
- .to_a
322
+ result.to_a
156
323
  end
157
324
 
158
325
  # Loop through records in a result set
159
326
  def each(&)
160
- @database
161
- .exec_stmt(Utils.stmt_name(@table, sql), sql, params)
162
- .each(&)
327
+ result.each(&)
163
328
  end
164
329
 
165
330
  # Explain some query
@@ -189,6 +354,8 @@ module PGI
189
354
  def count
190
355
  @command = "SELECT COUNT(*) FROM #{@table}"
191
356
  @order = {}
357
+ @select = [] # projection is irrelevant to a COUNT (and would corrupt it)
358
+ @projected = [] # likewise: an aggregate has no row to enrich
192
359
  first&.fetch("count", 0)
193
360
  end
194
361
 
@@ -199,6 +366,105 @@ module PGI
199
366
  "#<PGI::Dataset::Query:#{object_id} @sql=#{sql} @params=#{params}>"
200
367
  end
201
368
  alias inspect to_s
369
+
370
+ private
371
+
372
+ # Run the query and guard against a duplicate result field name before
373
+ # the rows are handed back (see #assert_result_unambiguous!).
374
+ #
375
+ # @return [PG::Result]
376
+ def result
377
+ res = @database.exec_stmt(Utils.stmt_name(@table, sql), sql, params)
378
+ assert_result_unambiguous!(res)
379
+ res
380
+ end
381
+
382
+ # Raise when two columns land on the same result field name - the row
383
+ # hash would silently keep only the last, losing data.
384
+ #
385
+ # A plain base.* read cannot clash, so only an appended column can:
386
+ # #select's joined column or an opted-in projections: entry.
387
+ #
388
+ # @param result [PG::Result]
389
+ # @raise [RuntimeError] listing the duplicated field name(s)
390
+ def assert_result_unambiguous!(result)
391
+ return if @select.empty? && @projected.empty?
392
+
393
+ dups = result.fields.tally.select { |_, n| n > 1 }.keys
394
+ return if dups.empty?
395
+
396
+ raise "Ambiguous result column(s): #{dups.join(", ")} - alias a joined column " \
397
+ "with select(table => { column: :alias }), or rename the colliding projections: entry"
398
+ end
399
+
400
+ # Render a #select column: a bare column (base table), a { table => column }
401
+ # pair (qualified), or a { table => { column => alias } } pair (qualified,
402
+ # AS "alias").
403
+ #
404
+ # @param column [Symbol, Hash] the column reference
405
+ # @raise [RuntimeError] if the Hash form is malformed or names an unknown table
406
+ # @return [String] the sanitized projection fragment
407
+ def projected_column(column)
408
+ return qualified_column(column) unless column.is_a?(Hash) && column.values.first.is_a?(Hash)
409
+
410
+ raise "Aliased column must be a single { table => { column => alias } } pair" unless column.size == 1
411
+
412
+ table, mapping = column.first
413
+ raise "Aliased column must map a single column to a single alias" unless mapping.size == 1
414
+
415
+ col, as = mapping.first
416
+ assert_known_table!(table)
417
+ "#{Utils.sanitize_column(col, table)} AS #{Utils.sanitize_column(as)}"
418
+ end
419
+
420
+ # Sanitize a column reference: a bare column qualifies with the base
421
+ # table; a single-pair Hash ({ users: :name }) qualifies with that table,
422
+ # which must be the base table or a joined table.
423
+ #
424
+ # @param column [Symbol, Hash] column or { table => column }
425
+ # @raise [RuntimeError] if the Hash form is malformed or names an unknown table
426
+ # @return [String] sanitized, qualified column
427
+ def qualified_column(column)
428
+ return Utils.sanitize_column(column, @table) unless column.is_a?(Hash)
429
+
430
+ raise "Qualified column must be a single { table => column } pair" unless column.size == 1
431
+
432
+ table, col = column.first
433
+ assert_known_table!(table)
434
+ Utils.sanitize_column(col, table)
435
+ end
436
+
437
+ # Escape LIKE/ILIKE metacharacters (\ % _) so a search term matches
438
+ # literally. Backslash is Postgres' default LIKE escape character, so no
439
+ # ESCAPE clause is needed - the escaped value binds straight as a param.
440
+ def escape_like(term)
441
+ term.to_s.gsub(/[\\%_]/) { |c| "\\#{c}" }
442
+ end
443
+
444
+ # A declared projection's trusted expression, parenthesised; nil for any other name.
445
+ def projected_term(name)
446
+ return if name.is_a?(Hash)
447
+
448
+ expr = @projections[name.to_sym] || @projections[name.to_s]
449
+ expr && "(#{expr})"
450
+ end
451
+
452
+ def assert_known_table!(table)
453
+ return if table.to_sym == @table.to_sym || @join_tables.include?(table.to_sym)
454
+
455
+ raise "Unknown table #{table.inspect} - qualify only the base table or joined tables"
456
+ end
457
+
458
+ # A sort term with an optional COLLATE: a declared projection's
459
+ # expression, else a column. Collation names are validated and quoted.
460
+ def collated_column(column, collate)
461
+ col = projected_term(column) || qualified_column(column)
462
+ return col unless collate
463
+
464
+ raise "Invalid collation: #{collate.inspect}" unless collate.to_s.match?(COLLATION_NAME)
465
+
466
+ %(#{col} COLLATE "#{collate}")
467
+ end
202
468
  end
203
469
  end
204
470
  end
@@ -29,6 +29,16 @@ module PGI
29
29
  "#{table}_#{Digest::MD5.hexdigest(sql)}"
30
30
  end
31
31
 
32
+ # Build "(expr) AS name" fragments from a projections declaration.
33
+ # Names pass the column sanitizer; expressions are TRUSTED raw SQL -
34
+ # authored at dataset-extension time like :scope, never request data.
35
+ #
36
+ # @param projections [Hash] name => SQL expression
37
+ # @return [Array<String>] fragments ready for a select/RETURNING list
38
+ def projection_fragments(projections)
39
+ projections.map { |name, expr| "(#{expr}) AS #{sanitize_column(name)}" }
40
+ end
41
+
32
42
  # Get a sanitized column name(s)
33
43
  #
34
44
  # @param columns [String|Array] the column name(s) to sanitize
data/lib/pgi/dataset.rb CHANGED
@@ -21,6 +21,41 @@ module PGI
21
21
  Query.new(@database, @table, nil, **@options).where(*)
22
22
  end
23
23
 
24
+ # Start a query with an INNER JOIN so WHERE/ORDER BY can reference the
25
+ # joined table's columns (filtering and sorting only - result rows stay
26
+ # base-table rows). Like #where, the returned Query yields raw row hashes;
27
+ # model mapping is the privilege of Dataset methods - for a paginated,
28
+ # model-mapped joined read use #page with the joins: keyword.
29
+ #
30
+ # @param table [Symbol] the table to join
31
+ # @param on [Hash] base-table column(s) => joined-table column(s)
32
+ # @return [Query]
33
+ def join(table, on:, type: :inner)
34
+ Query.new(@database, @table, nil, **@options).join(table, on: on, type: type)
35
+ end
36
+
37
+ # Start a query with a case-insensitive substring search across the given
38
+ # columns (see Query#search). Like #where/#join the returned Query yields
39
+ # raw row hashes; for a paginated, model-mapped search use #page with the
40
+ # search: keyword.
41
+ #
42
+ # @param columns [Array] the text columns to match against
43
+ # @param terms [Array<String>] search terms, already tokenised by the caller
44
+ # @return [Query]
45
+ def search(columns, terms)
46
+ Query.new(@database, @table, nil, **@options).search(columns, terms)
47
+ end
48
+
49
+ # Start a query with declared projections opted in (see Query#project).
50
+ # Like #where, yields raw row hashes; for a paginated, model-mapped
51
+ # projected read use #page with the project: keyword.
52
+ #
53
+ # @param names [Array<Symbol>] declared projection names
54
+ # @return [Query]
55
+ def project(*names)
56
+ Query.new(@database, @table, nil, **@options).project(*names)
57
+ end
58
+
24
59
  # Insert new row
25
60
  #
26
61
  # @param args [Hash|Object] row data
@@ -121,10 +156,33 @@ module PGI
121
156
  # @param size [Integer] number of rows per page
122
157
  # @param sort_by [Symbol] column to sort by
123
158
  # @param sort_dir [Symbol] :asc or :desc
159
+ # Joins (filter/sort only - result rows stay base-table rows) are passed
160
+ # as data: joins: { users: { user_id: :id } } maps joined table => on
161
+ # mapping, enabling qualified where ({ users: { name: "x" } }) and a
162
+ # qualified sort_by ({ users: :name }). Keyset over a joined sort column
163
+ # requires at most one joined row per base row (e.g. FK -> PK). An on-key
164
+ # may itself be qualified ({ { memberships: :user_id } => :id }) to chain a
165
+ # second hop off an earlier join; declare the joins in dependency order (an
166
+ # ordered Hash preserves it).
167
+ #
124
168
  # @param where [Array] optional WHERE clause forwarded to Query#where
169
+ # @param joins [Hash] joined table => on-mapping, forwarded to Query#join
170
+ # @param search [Hash, nil] { columns:, terms: } forwarded to Query#search -
171
+ # a substring search AND'ed into the WHERE, keyset-compatible (it only
172
+ # adds a predicate; the cursor stays on the sort column). Columns may be
173
+ # qualified with a joined table, so a search can span the same joins.
174
+ # @param collate [String, nil] collation for the sort column (e.g.
175
+ # "da-x-icu"), forwarded to Query#keyset
125
176
  # @return [Array] list of Models or Hashes
126
- def page(cursor = nil, size = 10, sort_by = :id, sort_dir = :asc, *where)
127
- _to_models Query.new(@database, @table, nil, **@options).where(*where).limit(size).keyset(sort_by, cursor, sort_dir).to_a
177
+ def page(cursor = nil, size = 10, sort_by = :id, sort_dir = :asc, *where, joins: {}, search: nil, collate: nil,
178
+ project: [])
179
+ query = Query.new(@database, @table, nil, **@options)
180
+ query.project(*project) if project.any?
181
+ joins.each { |table, on| query.join(table, on: on) }
182
+ query.where(*where)
183
+ query.search(search[:columns], search[:terms]) if search
184
+
185
+ _to_models query.limit(size).keyset(sort_by, cursor, sort_dir, collate: collate).to_a
128
186
  end
129
187
 
130
188
  private
@@ -136,7 +194,10 @@ module PGI
136
194
  # @param attributes [Hash] column => value
137
195
  # @return [Array(Array, Array, Array)] sanitized columns, placeholders, values
138
196
  def sql_params(attributes)
139
- attrs = attributes.sort.to_h
197
+ # Projections are READ-ONLY facts computed by the dataset - a model built
198
+ # from a projected read carries them, so writes must shed them silently.
199
+ projections = @options.fetch(:projections, {})
200
+ attrs = attributes.reject { |k, _| projections.key?(k.to_sym) }.sort.to_h
140
201
  [Utils.sanitize_columns(attrs.keys), (1..attrs.size).map { |i| "$#{i}" }, attrs.values]
141
202
  end
142
203
 
@@ -160,6 +221,11 @@ module PGI
160
221
  def [](database, table, **options)
161
222
  raise "Invalid table name: #{table}" unless table.to_s =~ /\A[a-z_][a-z0-9_]*\z/
162
223
 
224
+ options.fetch(:projections, {}).each do |name, expr|
225
+ raise "Invalid projection name: #{name.inspect}" unless Utils.valid_column?(name) && name.to_s != "*"
226
+ raise "Invalid projection expression for #{name.inspect}" unless expr.is_a?(String) && !expr.strip.empty?
227
+ end
228
+
163
229
  mod = clone
164
230
  mod.instance_variable_set("@database", database)
165
231
  mod.instance_variable_set("@table", table)
data/lib/pgi/db.rb CHANGED
@@ -20,19 +20,24 @@ module PGI
20
20
  @retry_wait = retry_wait
21
21
  end
22
22
 
23
+ # Build a DB from a block of options. The options are a local, so the pool
24
+ # block closes over this call's values - a second DB never takes over this one.
23
25
  def self.configure
24
- @options = Struct.new(
26
+ options = Struct.new(
25
27
  :pool_size, :pool_timeout, :pg_conn_uri, :logger, :max_retries, :retry_wait
26
28
  ).new
27
29
 
28
- yield @options
30
+ yield options
29
31
 
30
- pool = ConnectionPool.new(size: @options.pool_size, timeout: @options.pool_timeout) do
31
- Connection.new(conn_uri: @options.pg_conn_uri, logger: @options.logger)
32
+ # Fail where the knob was set, not at the first checkout; "" asks libpq for its defaults.
33
+ raise ArgumentError, "pg_conn_uri required" if options.pg_conn_uri.nil?
34
+
35
+ pool = ConnectionPool.new(size: options.pool_size, timeout: options.pool_timeout) do
36
+ Connection.new(conn_uri: options.pg_conn_uri, logger: options.logger)
32
37
  end
33
38
 
34
- retry_options = { max_retries: @options.max_retries, retry_wait: @options.retry_wait }.compact
35
- new(pool, @options.logger, **retry_options)
39
+ retry_options = { max_retries: options.max_retries, retry_wait: options.retry_wait }.compact
40
+ new(pool, options.logger, **retry_options)
36
41
  end
37
42
 
38
43
  # wrapper around ConnectionPool#with with auto-healing capabilities
data/pgi.gemspec CHANGED
@@ -18,7 +18,7 @@ Gem::Specification.new do |gem|
18
18
  gem.require_paths = ["lib"]
19
19
  gem.files = Dir["lib/**/*", "CHANGELOG.md", "LICENSE", "README.md", "VERSION", "pgi.gemspec"]
20
20
 
21
- gem.add_dependency "connection_pool", "~> 2.4"
21
+ gem.add_dependency "connection_pool", ">= 2.4", "< 4"
22
22
  gem.add_dependency "pg", "~> 1.5"
23
23
 
24
24
  gem.metadata["rubygems_mfa_required"] = "true"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgi
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Coherify
@@ -13,16 +13,22 @@ dependencies:
13
13
  name: connection_pool
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '2.4'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '2.4'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: pg
28
34
  requirement: !ruby/object:Gem::Requirement