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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00a4f48d4d9337019f30a191e60474cb75ff45a19c1653d0bf8a1897dd6551bf
4
- data.tar.gz: 2187c091a690e52b9d66efdbe7038932d6cc783aab8d95516246fe0ca564d48d
3
+ metadata.gz: 0e861fe364065922d9e21beb03770004e3645a31b2cd9fe827b0a517aa35739e
4
+ data.tar.gz: 45e6948a019f2194f4d1f2cc49ab81d575c55d16014baf3585f00a85b957374e
5
5
  SHA512:
6
- metadata.gz: 2cd4e11c2341f5151ba6b09549221e6345f0f9d1442c36d9631b14242815b95b470096524229e94296bdc35811f966eb5ff4a7250c1de442678d73cd498f81b6
7
- data.tar.gz: 267ba7b3dc84f507b6d47407cc77a42e6bc3a42fad1797c36494a90d672a2cf5a1dffed85ddf3953dc55666e00e37cdf8e08c9883fd1a8bce761a3b078897d6a
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
- 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,57 @@ 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
+ * `#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
- ### How keyset pagination works
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
- `#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.
221
+ ### Projections computed columns you opt into
86
222
 
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
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
- `LIMIT/OFFSET` scans and discards all prior rows on every page request — cost grows linearly with depth. Keyset pagination does not.
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 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.
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
- ## Documentation
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
- Create developer/test DB:
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.0.0
1
+ 1.1.0
@@ -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
@@ -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
- clause = clause.map do |k, v|
42
- @params << v
43
- "#{Utils.sanitize_columns(k, @table).first} = $#{@params.size}"
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
- raise "Use placeholders in WHERE clause" if clause =~ /=(?!\s*[?$])/
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 columns
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[Utils.sanitize_columns(column, @table)] = direction.to_s.upcase
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
- order(sort_by, sort_dir)
99
- order(:id, sort_dir) unless sort_by.to_sym == :id
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.sanitize_columns(:id, @table).first
251
+ id_col = Utils.sanitize_column(:id, @table)
104
252
  @params << cursor_id
105
253
 
106
254
  clause =
107
- if sort_by.to_sym == :id
255
+ if sort_on_id
108
256
  "#{id_col} #{op} $#{@params.size}"
109
257
  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})"
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
- @database
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
- @database
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
- @database
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
@@ -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,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
- attrs = attributes.sort.to_h
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)
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.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Coherify