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