pgi 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e861fe364065922d9e21beb03770004e3645a31b2cd9fe827b0a517aa35739e
4
- data.tar.gz: 45e6948a019f2194f4d1f2cc49ab81d575c55d16014baf3585f00a85b957374e
3
+ metadata.gz: e57eefff746743d0e70c5c256fa2fed74f2335c07b9657c67e0f517a5a37b927
4
+ data.tar.gz: '0073639115a44fe351077c3577b7e355da0165e580683c17620fc50b824303fe'
5
5
  SHA512:
6
- metadata.gz: 22c23e207efec5a443f93c316dad62e556ad63bffe3d18b618d26dc211d56676a51789f94ccdeb9acb791433b3c94b76d6145e455358435ed8dff193090d7519
7
- data.tar.gz: 2a6f49983336cedaf4997d625923964611336f309c686f8b548d484324dcf110c1a71da20e8e0c7618a8c1909d043be341437dea3cef3293ec71a8e0b0ffb1c4
6
+ metadata.gz: 7b981f438da0b1f82946471622922d1eee510cb135b67f3db5fa108e24fdb2b7936648c55385841f13d67bfc1a479c48c78e6298701a4d962abc2f69e2fbb614
7
+ data.tar.gz: 38a762ec93398e240563e2dfc46d3b7ae8d8e3d1c08938c23c25e90ad2286789e4b1b4b553c7f0185d1146ea6c8acb7cf80ab8370678d36e43a9f753f4964c92
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## Unreleased
4
+
5
+ - **Sort on a projection** — `#order` and `#page`/`#keyset` accept a declared
6
+ `projections:` name as the sort column and order (and seek) on its
7
+ expression, so a list can page on a computed value.
8
+
9
+ - **`DB.configure` requires `pg_conn_uri`** — leaving it unset used to pass
10
+ `configure` and raise at the first pool checkout, naming `Connection.new`'s
11
+ keywords instead of the knob you set; it now raises `ArgumentError` there and
12
+ then. Pass `pg_conn_uri: ""` to ask libpq for its defaults on purpose.
13
+
14
+ - **`DB.configure` no longer shares state between databases** (bug fix) — the
15
+ options lived on the class and the pool read them at checkout time, so a second
16
+ `configure` silently took over the first DB's pool. Each call now keeps its
17
+ own options.
18
+
19
+ - **`Connection.new` requires a connection** — building one with neither
20
+ `conn:` nor `conn_uri:` now raises `ArgumentError` instead of connecting to
21
+ whatever libpq's environment defaults point at. Pass `conn_uri: ""` to ask
22
+ for those defaults on purpose.
23
+
24
+ - **A colliding projection name raises** (behaviour change) — a `projections:`
25
+ entry named after a base column landed as a second result field and silently
26
+ overwrote the base value; the read now raises, as an unaliased joined column
27
+ from `#select` already did.
28
+
29
+ - **Dependency updates**
30
+ - connection_pool: the dependency widens from `~> 2.4` to `>= 2.4, < 4`.
31
+
3
32
  ## 1.1.0 (2026-08-28)
4
33
 
5
34
  The query release: joins, search, projected columns — and server notices that
data/README.md CHANGED
@@ -58,8 +58,10 @@ Repository.page(nil, 20, :name, :asc) # first page of 20, sorted by name
58
58
 
59
59
  The pieces:
60
60
 
61
- * `#select(column1, ...)` — limit the result set to the specified columns
62
- (also appends **joined** columns, see [Projecting joined columns](#projecting-joined-columns))
61
+ * `Dataset#select(column1, ...)` — start a query limited to the specified
62
+ columns of the base table
63
+ * `Query#select(column1, ...)` — append a **joined** table's column onto the
64
+ base table's columns, see [Projecting joined columns](#projecting-joined-columns)
63
65
  * `#where(...)` — can only be called once per query, so combine all conditions
64
66
  in a single call. Two forms:
65
67
  * `#where("name = ? AND age > ?", ['joe', 21])` — a string clause with placeholders (`?` or `$1`)
@@ -252,8 +254,17 @@ Notes:
252
254
  filtering **on** a projection promotes evaluation to the whole scope; that
253
255
  EXPLAIN is the caller's to own. If a projection ever measures hot, the
254
256
  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
+ - **Sort on a projection** — `#order` and `#page`/`#keyset` take a declared
258
+ projection's name as the sort column and order on its expression; the
259
+ read need not project it. The cursor row resolves the same expression.
260
+
261
+ ```ruby
262
+ Repository.page(nil, 20, :mates_count, :desc)
263
+ ```
264
+ - **A colliding name raises** — a projection named after a base column would
265
+ overwrite it in the row hash, so the read raises when the rows come back
266
+ (same guard as an unaliased joined column). Pick names that cannot collide
267
+ (`mates_count`, not `count`).
257
268
 
258
269
  ### Search
259
270
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.2.0
@@ -10,9 +10,12 @@ module PGI
10
10
 
11
11
  # Create instance
12
12
  #
13
- # @param pool [ConnectionPool]
14
13
  # @param logger [Logger]
14
+ # @param conn_uri [String] connection URI; "" asks libpq for its own defaults
15
+ # @param conn [PG::Connection] an already built connection, used as is
15
16
  def initialize(logger:, conn_uri: nil, conn: nil)
17
+ raise ArgumentError, "conn or conn_uri required" unless conn || conn_uri
18
+
16
19
  @logger = logger
17
20
 
18
21
  @conn = conn || PG::Connection.new(conn_uri).tap do |new_conn|
@@ -24,7 +27,7 @@ module PGI
24
27
 
25
28
  new_conn.type_map_for_results = PG::BasicTypeMapForResults.new(new_conn, registry: regi)
26
29
  new_conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(new_conn, registry: regi)
27
- end || raise("no connection provided")
30
+ end
28
31
 
29
32
  # Server notices (a DROP CASCADE's chatter, a RAISE NOTICE) route to
30
33
  # the configured logger instead of libpq's stderr default: a library
@@ -37,7 +37,8 @@ module PGI
37
37
  # Opt into declared projections for THIS read: the named computed
38
38
  # columns join the select list as (expr) AS name. Names must be
39
39
  # declared on the dataset (the trust boundary stays at extension
40
- # time); unknown names raise.
40
+ # time); unknown names raise. A name that collides with a base column
41
+ # raises when the rows come back, rather than clobbering it.
41
42
  #
42
43
  # @param names [Array<Symbol>] declared projection names
43
44
  # @return [Query] the Query instance (for method chaining)
@@ -201,7 +202,8 @@ module PGI
201
202
  # Adds a ORDER BY clause to the query - suports multiple calls to the method
202
203
  #
203
204
  # @param column [Symbol, Hash] the column - a single-pair Hash qualifies
204
- # it with a joined table: { users: :name }
205
+ # it with a joined table: { users: :name }; a declared projection's
206
+ # name sorts on its expression
205
207
  # @param direction [Symbol] the direction the sort should take - can be either `:desc` or `:asc`
206
208
  # @param collate [String, nil] collation for text ordering, e.g. "da-x-icu"
207
209
  # (policy - which locale maps to which collation - belongs to the caller)
@@ -234,7 +236,7 @@ module PGI
234
236
  # Do not combine with a conflicting #order call — pages are only correct when
235
237
  # the leading sort columns match the cursor predicate.
236
238
  #
237
- # @param sort_by [Symbol] the sort column
239
+ # @param sort_by [Symbol] the sort column, or a declared projection's name
238
240
  # @param cursor_id [*, nil] id of the last row from the previous page, or nil for the first page
239
241
  # @param sort_dir [Symbol] :asc or :desc
240
242
  # @param collate [String, nil] collation for the sort column. Must be the
@@ -367,31 +369,32 @@ module PGI
367
369
 
368
370
  private
369
371
 
370
- # Run the query and guard against a projected-column name collision
371
- # before the rows are handed back (see #select).
372
+ # Run the query and guard against a duplicate result field name before
373
+ # the rows are handed back (see #assert_result_unambiguous!).
372
374
  #
373
375
  # @return [PG::Result]
374
376
  def result
375
377
  res = @database.exec_stmt(Utils.stmt_name(@table, sql), sql, params)
376
- assert_projection_unambiguous!(res)
378
+ assert_result_unambiguous!(res)
377
379
  res
378
380
  end
379
381
 
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.
382
+ # Raise when two columns land on the same result field name - the row
383
+ # hash would silently keep only the last, losing data.
384
+ #
385
+ # A plain base.* read cannot clash, so only an appended column can:
386
+ # #select's joined column or an opted-in projections: entry.
384
387
  #
385
388
  # @param result [PG::Result]
386
389
  # @raise [RuntimeError] listing the duplicated field name(s)
387
- def assert_projection_unambiguous!(result)
388
- return if @select.empty?
390
+ def assert_result_unambiguous!(result)
391
+ return if @select.empty? && @projected.empty?
389
392
 
390
393
  dups = result.fields.tally.select { |_, n| n > 1 }.keys
391
394
  return if dups.empty?
392
395
 
393
- raise "Ambiguous projected column(s): #{dups.join(", ")} - alias with " \
394
- "select(table => { column: :alias }) to disambiguate"
396
+ raise "Ambiguous result column(s): #{dups.join(", ")} - alias a joined column " \
397
+ "with select(table => { column: :alias }), or rename the colliding projections: entry"
395
398
  end
396
399
 
397
400
  # Render a #select column: a bare column (base table), a { table => column }
@@ -438,16 +441,24 @@ module PGI
438
441
  term.to_s.gsub(/[\\%_]/) { |c| "\\#{c}" }
439
442
  end
440
443
 
444
+ # A declared projection's trusted expression, parenthesised; nil for any other name.
445
+ def projected_term(name)
446
+ return if name.is_a?(Hash)
447
+
448
+ expr = @projections[name.to_sym] || @projections[name.to_s]
449
+ expr && "(#{expr})"
450
+ end
451
+
441
452
  def assert_known_table!(table)
442
453
  return if table.to_sym == @table.to_sym || @join_tables.include?(table.to_sym)
443
454
 
444
455
  raise "Unknown table #{table.inspect} - qualify only the base table or joined tables"
445
456
  end
446
457
 
447
- # A column reference with an optional COLLATE. Collation names are
448
- # identifiers (e.g. "da-x-icu"), validated and quoted - never params.
458
+ # A sort term with an optional COLLATE: a declared projection's
459
+ # expression, else a column. Collation names are validated and quoted.
449
460
  def collated_column(column, collate)
450
- col = qualified_column(column)
461
+ col = projected_term(column) || qualified_column(column)
451
462
  return col unless collate
452
463
 
453
464
  raise "Invalid collation: #{collate.inspect}" unless collate.to_s.match?(COLLATION_NAME)
data/lib/pgi/dataset.rb CHANGED
@@ -194,9 +194,8 @@ module PGI
194
194
  # @param attributes [Hash] column => value
195
195
  # @return [Array(Array, Array, Array)] sanitized columns, placeholders, values
196
196
  def sql_params(attributes)
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.
197
+ # Projections are READ-ONLY facts computed by the dataset - a model built
198
+ # from a projected read carries them, so writes must shed them silently.
200
199
  projections = @options.fetch(:projections, {})
201
200
  attrs = attributes.reject { |k, _| projections.key?(k.to_sym) }.sort.to_h
202
201
  [Utils.sanitize_columns(attrs.keys), (1..attrs.size).map { |i| "$#{i}" }, attrs.values]
data/lib/pgi/db.rb CHANGED
@@ -20,19 +20,24 @@ module PGI
20
20
  @retry_wait = retry_wait
21
21
  end
22
22
 
23
+ # Build a DB from a block of options. The options are a local, so the pool
24
+ # block closes over this call's values - a second DB never takes over this one.
23
25
  def self.configure
24
- @options = Struct.new(
26
+ options = Struct.new(
25
27
  :pool_size, :pool_timeout, :pg_conn_uri, :logger, :max_retries, :retry_wait
26
28
  ).new
27
29
 
28
- yield @options
30
+ yield options
29
31
 
30
- pool = ConnectionPool.new(size: @options.pool_size, timeout: @options.pool_timeout) do
31
- Connection.new(conn_uri: @options.pg_conn_uri, logger: @options.logger)
32
+ # Fail where the knob was set, not at the first checkout; "" asks libpq for its defaults.
33
+ raise ArgumentError, "pg_conn_uri required" if options.pg_conn_uri.nil?
34
+
35
+ pool = ConnectionPool.new(size: options.pool_size, timeout: options.pool_timeout) do
36
+ Connection.new(conn_uri: options.pg_conn_uri, logger: options.logger)
32
37
  end
33
38
 
34
- retry_options = { max_retries: @options.max_retries, retry_wait: @options.retry_wait }.compact
35
- new(pool, @options.logger, **retry_options)
39
+ retry_options = { max_retries: options.max_retries, retry_wait: options.retry_wait }.compact
40
+ new(pool, options.logger, **retry_options)
36
41
  end
37
42
 
38
43
  # wrapper around ConnectionPool#with with auto-healing capabilities
data/pgi.gemspec CHANGED
@@ -18,7 +18,7 @@ Gem::Specification.new do |gem|
18
18
  gem.require_paths = ["lib"]
19
19
  gem.files = Dir["lib/**/*", "CHANGELOG.md", "LICENSE", "README.md", "VERSION", "pgi.gemspec"]
20
20
 
21
- gem.add_dependency "connection_pool", "~> 2.4"
21
+ gem.add_dependency "connection_pool", ">= 2.4", "< 4"
22
22
  gem.add_dependency "pg", "~> 1.5"
23
23
 
24
24
  gem.metadata["rubygems_mfa_required"] = "true"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgi
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Coherify
@@ -13,16 +13,22 @@ dependencies:
13
13
  name: connection_pool
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '2.4'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '2.4'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: pg
28
34
  requirement: !ruby/object:Gem::Requirement