pgi 1.0.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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +20 -0
- data/README.md +117 -0
- data/VERSION +1 -0
- data/lib/pgi/connection.rb +69 -0
- data/lib/pgi/dataset/query.rb +204 -0
- data/lib/pgi/dataset/utils.rb +67 -0
- data/lib/pgi/dataset.rb +180 -0
- data/lib/pgi/db.rb +105 -0
- data/lib/pgi/schema_migrator.rb +121 -0
- data/lib/pgi/tasks.rake +55 -0
- data/lib/pgi/version.rb +3 -0
- data/lib/pgi.rb +4 -0
- data/pgi.gemspec +27 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 00a4f48d4d9337019f30a191e60474cb75ff45a19c1653d0bf8a1897dd6551bf
|
|
4
|
+
data.tar.gz: 2187c091a690e52b9d66efdbe7038932d6cc783aab8d95516246fe0ca564d48d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2cd4e11c2341f5151ba6b09549221e6345f0f9d1442c36d9631b14242815b95b470096524229e94296bdc35811f966eb5ff4a7250c1de442678d73cd498f81b6
|
|
7
|
+
data.tar.gz: 267ba7b3dc84f507b6d47407cc77a42e6bc3a42fad1797c36494a90d672a2cf5a1dffed85ddf3953dc55666e00e37cdf8e08c9883fd1a8bce761a3b078897d6a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- `PGI::DB` — pooled PostgreSQL access with auto-healing: lost connections and
|
|
8
|
+
pool checkout timeouts share a configurable retry budget (`max_retries`,
|
|
9
|
+
`retry_wait`; `Float::INFINITY` rides out arbitrarily long outages)
|
|
10
|
+
- `PGI::Connection` — thin `PG::Connection` wrapper with lazily auto-prepared
|
|
11
|
+
statements and typed results (uuid, json/jsonb with symbolized keys)
|
|
12
|
+
- `PGI::Dataset` — super lightweight repository toolkit: CRUD, scoped queries
|
|
13
|
+
and keyset pagination with a scalar id cursor
|
|
14
|
+
- `PGI::SchemaMigrator` — plain up/down SQL migrations tracked in a
|
|
15
|
+
`schema_migrations` table
|
data/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2022 Coherify ApS
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# PGI
|
|
2
|
+
|
|
3
|
+
PGI is a simple and convenient interface for PostgreSQL with a few enhancements.
|
|
4
|
+
|
|
5
|
+
## PGI::DB
|
|
6
|
+
|
|
7
|
+
The `PGI::DB` handles connections to a PostgreSQL databases. It features...
|
|
8
|
+
|
|
9
|
+
* Connection Pool
|
|
10
|
+
* Connection auto-healing capabilities
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
DB = PGI::DB.configure do |options|
|
|
16
|
+
options.pool_size = 1
|
|
17
|
+
options.pool_timeout = 5
|
|
18
|
+
options.pg_conn_uri = "postgresql://pgi:password@localhost:5432/pgi_test"
|
|
19
|
+
options.logger = Logger.new($stdout)
|
|
20
|
+
options.max_retries = 30 # optional (default: 30, ~1 min of patience) - shared retry budget for lost connections
|
|
21
|
+
# and pool checkout timeouts; Float::INFINITY rides out any outage
|
|
22
|
+
options.retry_wait = 2 # optional (default: 2) - seconds between reconnection attempts
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
DB.exec_stmt("my_stmt", "SELECT 1+1")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## PGI::Dataset
|
|
29
|
+
|
|
30
|
+
The `PGI::Dataset` is a super light weight ActiveRecord::Relation replacement. It delivers a clean and simple querying interface:
|
|
31
|
+
|
|
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
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class Repository
|
|
45
|
+
extend PGI::Dataset[DB, :members, scope: "deleted_at IS NULL"]
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
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);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# First page — sorted by name
|
|
59
|
+
page1 = Repository.page(nil, 20, :name, :asc)
|
|
60
|
+
|
|
61
|
+
# Next page — pass the id of the last row as the cursor
|
|
62
|
+
page2 = Repository.page(page1.last["id"], 20, :name, :asc)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Generated SQL for page 2 (`sort_by != :id`):
|
|
66
|
+
```sql
|
|
67
|
+
SELECT * FROM members
|
|
68
|
+
WHERE deleted_at IS NULL
|
|
69
|
+
AND (name, id) > (SELECT name, id FROM members WHERE id = $1)
|
|
70
|
+
ORDER BY name ASC, id ASC
|
|
71
|
+
LIMIT 20
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Generated SQL for page 2 (`sort_by == :id`):
|
|
75
|
+
```sql
|
|
76
|
+
SELECT * FROM members
|
|
77
|
+
WHERE deleted_at IS NULL
|
|
78
|
+
AND id > $1
|
|
79
|
+
ORDER BY id ASC
|
|
80
|
+
LIMIT 20
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### How keyset pagination works
|
|
84
|
+
|
|
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.
|
|
86
|
+
|
|
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.
|
|
94
|
+
|
|
95
|
+
`LIMIT/OFFSET` scans and discards all prior rows on every page request — cost grows linearly with depth. Keyset pagination does not.
|
|
96
|
+
|
|
97
|
+
### Constraints
|
|
98
|
+
|
|
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.
|
|
102
|
+
|
|
103
|
+
## Documentation
|
|
104
|
+
|
|
105
|
+
Dependencies:
|
|
106
|
+
|
|
107
|
+
* https://github.com/ged/ruby-pg
|
|
108
|
+
* https://github.com/mperham/connection_pool
|
|
109
|
+
|
|
110
|
+
Create developer/test DB:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
sudo su - postgres
|
|
114
|
+
psql -c "CREATE ROLE pgi WITH login password 'password';"
|
|
115
|
+
createdb --owner pgi pgi_test
|
|
116
|
+
psql pgi_test -c 'CREATE EXTENSION IF NOT EXISTS "pgcrypto";'
|
|
117
|
+
```
|
data/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "pg"
|
|
2
|
+
|
|
3
|
+
module PGI
|
|
4
|
+
class Connection
|
|
5
|
+
class JSONDecoder < PG::SimpleDecoder
|
|
6
|
+
def decode(string, _tuple = nil, _field = nil)
|
|
7
|
+
::JSON.parse(string, quirks_mode: true, symbolize_names: true)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Create instance
|
|
12
|
+
#
|
|
13
|
+
# @param pool [ConnectionPool]
|
|
14
|
+
# @param logger [Logger]
|
|
15
|
+
def initialize(logger:, conn_uri: nil, conn: nil)
|
|
16
|
+
@logger = logger
|
|
17
|
+
|
|
18
|
+
@conn = conn || PG::Connection.new(conn_uri).tap do |new_conn|
|
|
19
|
+
regi = PG::BasicTypeRegistry.new.register_default_types
|
|
20
|
+
|
|
21
|
+
regi.register_type 0, "uuid", PG::TextEncoder::String, PG::TextDecoder::String
|
|
22
|
+
regi.register_type 0, "json", PG::TextEncoder::JSON, JSONDecoder
|
|
23
|
+
regi.alias_type(0, "jsonb", "json")
|
|
24
|
+
|
|
25
|
+
new_conn.type_map_for_results = PG::BasicTypeMapForResults.new(new_conn, registry: regi)
|
|
26
|
+
new_conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(new_conn, registry: regi)
|
|
27
|
+
end || raise("no connection provided")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Execute a prepared statement. Statements are auto-created with fallback to exec_params
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# .exec_stmt("users_by_name", "SELECT * FROM users WHERE name = $1", ["joe"])
|
|
34
|
+
#
|
|
35
|
+
# @param stmt_name [String] name of statement, must be unique for the query
|
|
36
|
+
# @param sql [String] SQL query
|
|
37
|
+
# @param params [Array] list of params
|
|
38
|
+
def exec_stmt(stmt_name, sql, params = [])
|
|
39
|
+
if [PG::PQTRANS_ACTIVE, PG::PQTRANS_INTRANS, PG::PQTRANS_INERROR].include?(@conn.transaction_status)
|
|
40
|
+
@logger&.debug "Unable to use statements within a transaction - falling back to #exec_params"
|
|
41
|
+
return @conn.exec_params(sql, params)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
@conn.exec_prepared(stmt_name, params)
|
|
46
|
+
rescue PG::InvalidSqlStatementName
|
|
47
|
+
@logger&.debug "Creating missing prepared statement: \"#{stmt_name}\""
|
|
48
|
+
@conn.prepare(stmt_name, sql)
|
|
49
|
+
retry
|
|
50
|
+
end
|
|
51
|
+
rescue PG::Error => e
|
|
52
|
+
# Log at the source so failures are traceable regardless of how the
|
|
53
|
+
# consumer handles the exception
|
|
54
|
+
@logger&.error(e)
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Pass the remainder of methods on to a PG::Connection
|
|
59
|
+
#
|
|
60
|
+
# @See https://deveiate.org/code/pg/PG/Connection.html
|
|
61
|
+
def method_missing(name, ...)
|
|
62
|
+
@conn.__send__(name, ...)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def respond_to_missing?(name, include_private = false)
|
|
66
|
+
@conn.respond_to?(name, include_private) || super
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
require "digest/md5"
|
|
2
|
+
require "pgi/dataset/utils"
|
|
3
|
+
|
|
4
|
+
module PGI
|
|
5
|
+
module Dataset
|
|
6
|
+
class Query
|
|
7
|
+
# Create instance of Query
|
|
8
|
+
#
|
|
9
|
+
# @param database [PGI::DB] a configured instance of DB
|
|
10
|
+
# @param table [Symbol] the name of the database table to operate on
|
|
11
|
+
# @param command [String] the command part of the query (default: `SELECT * FROM <table>`)
|
|
12
|
+
# @param options [Hash] hash of options: scope, where, params, limit, order, returning
|
|
13
|
+
# @return [Query] new instance of Query
|
|
14
|
+
def initialize(database, table, command, **options)
|
|
15
|
+
@database = database
|
|
16
|
+
@table = table
|
|
17
|
+
@command = command || "SELECT * FROM #{@table}"
|
|
18
|
+
@scope = options.fetch(:scope, nil)
|
|
19
|
+
@where = options.fetch(:where, nil)
|
|
20
|
+
@params = options.fetch(:params, [])
|
|
21
|
+
@order = options.fetch(:order, {})
|
|
22
|
+
@limit = options.fetch(:limit, 10)
|
|
23
|
+
@returning = options.fetch(:returning, nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Adds a WHERE clause to the query - can only be called once per query,
|
|
27
|
+
# so combine all conditions in a single call
|
|
28
|
+
#
|
|
29
|
+
# @param clause [Hash, String] a Hash of table columns and values - or a string with placeholders
|
|
30
|
+
# @param params [Array] list of values for placeholder substitution
|
|
31
|
+
# @raise [RuntimeError] if a WHERE clause is already set
|
|
32
|
+
# @return [Query] return the Query instance (for method chaining)
|
|
33
|
+
def where(clause = nil, params = [])
|
|
34
|
+
return self unless clause
|
|
35
|
+
return self if clause.empty?
|
|
36
|
+
|
|
37
|
+
raise "WHERE clause already set - combine conditions in a single call" if @where
|
|
38
|
+
|
|
39
|
+
case clause
|
|
40
|
+
when Hash
|
|
41
|
+
clause = clause.map do |k, v|
|
|
42
|
+
@params << v
|
|
43
|
+
"#{Utils.sanitize_columns(k, @table).first} = $#{@params.size}"
|
|
44
|
+
end.join(" AND ")
|
|
45
|
+
when String
|
|
46
|
+
raise "Use placeholders in WHERE clause" if clause =~ /=(?!\s*[?$])/
|
|
47
|
+
|
|
48
|
+
offset = @params.size
|
|
49
|
+
@params += params
|
|
50
|
+
clause = clause.gsub(/([=<>]{1}\s{0,})(\?)/).with_index { |_, i| "#{Regexp.last_match(1)}$#{offset + i + 1}" }
|
|
51
|
+
else
|
|
52
|
+
raise "WHERE clause can either be a Hash or a String"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@where = clause
|
|
56
|
+
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Adds a ORDER BY clause to the query - suports multiple calls to the method
|
|
61
|
+
#
|
|
62
|
+
# @param column [Symbol] the columns
|
|
63
|
+
# @param direction [Symbol] the direction the sort should take - can be either `:desc` or `:asc`
|
|
64
|
+
# @raise [RuntimeError] if the direction param is invalid
|
|
65
|
+
# @return [Query] return the Query instance (for method chaining)
|
|
66
|
+
def order(column, direction = :asc)
|
|
67
|
+
raise "Invalid ORDER BY direction: #{direction.inspect}" unless %i[asc desc].include?(direction)
|
|
68
|
+
|
|
69
|
+
@order[Utils.sanitize_columns(column, @table)] = direction.to_s.upcase
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Adds a LIMIT clause to the query
|
|
74
|
+
#
|
|
75
|
+
# @param direction [Integer] the direction the sort should take - can be either `:desc` or `:asc`
|
|
76
|
+
# @raise [RuntimeError] if the direction param is invalid
|
|
77
|
+
# @return [Query] return the Query instance (for method chaining)
|
|
78
|
+
def limit(number)
|
|
79
|
+
raise "LIMIT must be an integer or nil" unless number.nil? || number.is_a?(Integer)
|
|
80
|
+
|
|
81
|
+
@limit = number
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Apply keyset pagination: orders by (sort_by, id) and, when a cursor id is
|
|
86
|
+
# given, seeks past that row. A nil cursor_id is the first page. The cursor
|
|
87
|
+
# predicate combines with any existing WHERE clause, so call it after #where.
|
|
88
|
+
# For sort_by == :id: WHERE id > $cursor_id
|
|
89
|
+
# For sort_by != :id: WHERE (sort_col, id) > (SELECT sort_col, id FROM table WHERE id = $cursor_id)
|
|
90
|
+
# Do not combine with a conflicting #order call — pages are only correct when
|
|
91
|
+
# the leading sort columns match the cursor predicate.
|
|
92
|
+
#
|
|
93
|
+
# @param sort_by [Symbol] the sort column
|
|
94
|
+
# @param cursor_id [*, nil] id of the last row from the previous page, or nil for the first page
|
|
95
|
+
# @param sort_dir [Symbol] :asc or :desc
|
|
96
|
+
# @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
|
|
100
|
+
return self unless cursor_id
|
|
101
|
+
|
|
102
|
+
op = sort_dir == :asc ? ">" : "<"
|
|
103
|
+
id_col = Utils.sanitize_columns(:id, @table).first
|
|
104
|
+
@params << cursor_id
|
|
105
|
+
|
|
106
|
+
clause =
|
|
107
|
+
if sort_by.to_sym == :id
|
|
108
|
+
"#{id_col} #{op} $#{@params.size}"
|
|
109
|
+
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})"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@where = @where ? "#{clause} AND (#{@where})" : clause
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get the Query SQL string prepared for execution
|
|
119
|
+
#
|
|
120
|
+
# @return [String] Query as a SQL string
|
|
121
|
+
def sql
|
|
122
|
+
# Simple Scope implementation
|
|
123
|
+
scope = @scope.dup
|
|
124
|
+
scope << " AND " if scope && @where
|
|
125
|
+
|
|
126
|
+
command = @command.dup
|
|
127
|
+
command << " WHERE #{scope}#{@where}" if @where || scope
|
|
128
|
+
command << " ORDER BY #{Array(@order).map { |x| x.join(" ") }.join(", ")}" unless @order.empty?
|
|
129
|
+
command << " LIMIT #{@limit}" if @limit
|
|
130
|
+
command << " RETURNING *" if @command =~ /^UPDATE|INSERT|DELETE/
|
|
131
|
+
command
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Get the params for placeholder substitution
|
|
135
|
+
#
|
|
136
|
+
# @return [Array] params
|
|
137
|
+
attr_reader :params
|
|
138
|
+
|
|
139
|
+
# Get the first record in a result set
|
|
140
|
+
#
|
|
141
|
+
# @return [Hash]
|
|
142
|
+
def first
|
|
143
|
+
limit(1)
|
|
144
|
+
@database
|
|
145
|
+
.exec_stmt(Utils.stmt_name(@table, sql), sql, params)
|
|
146
|
+
.first
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get all the records in a result set
|
|
150
|
+
#
|
|
151
|
+
# @return [Array] Array of records as Hashes
|
|
152
|
+
def to_a
|
|
153
|
+
@database
|
|
154
|
+
.exec_stmt(Utils.stmt_name(@table, sql), sql, params)
|
|
155
|
+
.to_a
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Loop through records in a result set
|
|
159
|
+
def each(&)
|
|
160
|
+
@database
|
|
161
|
+
.exec_stmt(Utils.stmt_name(@table, sql), sql, params)
|
|
162
|
+
.each(&)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Explain some query
|
|
166
|
+
#
|
|
167
|
+
# @return [String] Formatted string explaining query plan
|
|
168
|
+
def explain
|
|
169
|
+
explain_sql = "EXPLAIN " << sql.dup.tap do |s|
|
|
170
|
+
params.each_with_index do |x, i|
|
|
171
|
+
x =
|
|
172
|
+
case x
|
|
173
|
+
when String
|
|
174
|
+
"'#{x}'"
|
|
175
|
+
else
|
|
176
|
+
x
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
s.gsub!("$#{i + 1}", x.to_s)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
@database.exec(explain_sql)&.values&.join("\n")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get the number of records in a result set
|
|
187
|
+
#
|
|
188
|
+
# @return [Integer]
|
|
189
|
+
def count
|
|
190
|
+
@command = "SELECT COUNT(*) FROM #{@table}"
|
|
191
|
+
@order = {}
|
|
192
|
+
first&.fetch("count", 0)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Get a string representation of the instance
|
|
196
|
+
#
|
|
197
|
+
# @return [String]
|
|
198
|
+
def to_s
|
|
199
|
+
"#<PGI::Dataset::Query:#{object_id} @sql=#{sql} @params=#{params}>"
|
|
200
|
+
end
|
|
201
|
+
alias inspect to_s
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "digest/md5"
|
|
2
|
+
|
|
3
|
+
module PGI
|
|
4
|
+
module Dataset
|
|
5
|
+
module Utils
|
|
6
|
+
class << self
|
|
7
|
+
# Strips the fields to not insert off a hash
|
|
8
|
+
#
|
|
9
|
+
# @param attr [Hash] input hash
|
|
10
|
+
# @return [Hash] stripped hash
|
|
11
|
+
def strip_uninsertable(attr)
|
|
12
|
+
attr.except(:id, :created_at, :updated_at)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Strips the fields to not update off a hash
|
|
16
|
+
#
|
|
17
|
+
# @param attr [Hash] input hash
|
|
18
|
+
# @return [Hash] stripped hash
|
|
19
|
+
def strip_unupdateable(attr)
|
|
20
|
+
attr.except(:created_at, :updated_at)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get a unique statement name for the Query
|
|
24
|
+
#
|
|
25
|
+
# @param table [String] table name
|
|
26
|
+
# @param sql [String] SQL query
|
|
27
|
+
# @return [String] a statement name
|
|
28
|
+
def stmt_name(table, sql)
|
|
29
|
+
"#{table}_#{Digest::MD5.hexdigest(sql)}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get a sanitized column name(s)
|
|
33
|
+
#
|
|
34
|
+
# @param columns [String|Array] the column name(s) to sanitize
|
|
35
|
+
# @param table [Symbol] the table name
|
|
36
|
+
# @return [Array] list of sanitized column names
|
|
37
|
+
def sanitize_columns(columns, table = nil)
|
|
38
|
+
Array(columns).map do |col|
|
|
39
|
+
sanitize_column(col, table)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get a sanitized column name
|
|
44
|
+
#
|
|
45
|
+
# @param columns [String|Array] the column name(s) to sanitize
|
|
46
|
+
# @param table [Symbol] the table name
|
|
47
|
+
# @return [Array] list of sanitized column names
|
|
48
|
+
def sanitize_column(col, table = nil)
|
|
49
|
+
raise "invalid column name: #{col.inspect}" unless valid_column?(col)
|
|
50
|
+
|
|
51
|
+
return "*" if col == "*"
|
|
52
|
+
|
|
53
|
+
table ? %("#{table}"."#{col}") : %("#{col}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validates a column name
|
|
57
|
+
#
|
|
58
|
+
# @param columns [Symbol] the column name(s) to sanitize
|
|
59
|
+
# @return [Boolean] true if valid, otherwise false
|
|
60
|
+
def valid_column?(column)
|
|
61
|
+
col = column.to_s
|
|
62
|
+
col == "*" || col =~ /\A[a-z_][a-z0-9_]*\z/i
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/pgi/dataset.rb
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
require "pgi/dataset/query"
|
|
2
|
+
require "pgi/dataset/utils"
|
|
3
|
+
|
|
4
|
+
module PGI
|
|
5
|
+
module Dataset
|
|
6
|
+
# Select specific columns
|
|
7
|
+
#
|
|
8
|
+
# @param args [String|Array] list of columns to include in result set
|
|
9
|
+
# @return [Query]
|
|
10
|
+
def select(*args)
|
|
11
|
+
columns = args.empty? ? "*" : Utils.sanitize_columns(args, @table).join(", ")
|
|
12
|
+
command = "SELECT #{columns} FROM #{@table}"
|
|
13
|
+
Query.new(@database, @table, command, **@options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Select specfic rows
|
|
17
|
+
#
|
|
18
|
+
# @param args [String|Array] conditions to search for
|
|
19
|
+
# @return [Query]
|
|
20
|
+
def where(*)
|
|
21
|
+
Query.new(@database, @table, nil, **@options).where(*)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Insert new row
|
|
25
|
+
#
|
|
26
|
+
# @param args [Hash|Object] row data
|
|
27
|
+
# @return [Model,Hash]
|
|
28
|
+
def insert(**attributes)
|
|
29
|
+
attributes = Utils.strip_uninsertable(attributes)
|
|
30
|
+
|
|
31
|
+
insert!(**attributes)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def insert!(**attributes)
|
|
35
|
+
columns, placeholders, values = sql_params(attributes)
|
|
36
|
+
command = "INSERT INTO #{@table}"
|
|
37
|
+
command <<
|
|
38
|
+
if columns.empty?
|
|
39
|
+
" DEFAULT VALUES"
|
|
40
|
+
else
|
|
41
|
+
" (#{columns.join(", ")}) VALUES (#{placeholders.join(", ")}) "
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
_to_model Query.new(@database, @table, command, params: values).limit(nil).to_a.first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Update row
|
|
48
|
+
#
|
|
49
|
+
# @param id [*] ID of row
|
|
50
|
+
# @param args [Hash] data for update
|
|
51
|
+
# @return [Model,Hash]
|
|
52
|
+
def update(id, **args)
|
|
53
|
+
args = Utils.strip_unupdateable(args)
|
|
54
|
+
|
|
55
|
+
update!(id, **args)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def update!(id, **args)
|
|
59
|
+
columns, placeholders, values = sql_params(args)
|
|
60
|
+
set_clause = columns.zip(placeholders).map { |c, p| "#{c} = #{p}" }.join(", ")
|
|
61
|
+
command = "UPDATE #{@table} SET #{set_clause} " \
|
|
62
|
+
"WHERE #{Utils.sanitize_column(:id)} = $#{values.size + 1} RETURNING *"
|
|
63
|
+
|
|
64
|
+
# TODO: Query throws `PG::IndeterminateDatatype: ERROR: could not determine data type of parameter $2`
|
|
65
|
+
# _to_model Query.new(@database, @table, command, params: values + [id]).where(id: id).limit(nil)
|
|
66
|
+
|
|
67
|
+
_to_model @database.exec_stmt(Utils.stmt_name(@table, command), command, values + [id])&.first
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Delete row
|
|
71
|
+
#
|
|
72
|
+
# @param id [*] ID of row
|
|
73
|
+
# @return [Model,Hash]
|
|
74
|
+
def delete(id)
|
|
75
|
+
command = "DELETE FROM #{@table}"
|
|
76
|
+
_to_model Query.new(@database, @table, command, **@options).where(id: id).limit(nil).to_a.first
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get a row by its id
|
|
80
|
+
#
|
|
81
|
+
# @param id [*] ID of row
|
|
82
|
+
# @return [Model,Hash]
|
|
83
|
+
def find(id)
|
|
84
|
+
_to_model Query.new(@database, @table, nil, **@options).where(id: id).first
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get all rows
|
|
88
|
+
#
|
|
89
|
+
# @return [Array] list of Models, Hashes
|
|
90
|
+
def all
|
|
91
|
+
_to_models Query.new(@database, @table, nil, **@options).limit(nil).to_a
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get first row by column (default: :id)
|
|
95
|
+
#
|
|
96
|
+
# @return [Model,Hash]
|
|
97
|
+
def first(sort_by = :id)
|
|
98
|
+
_to_model where.order(sort_by.to_sym, :asc).first
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get last row by column (default: :id)
|
|
102
|
+
#
|
|
103
|
+
# @return [Model,Hash]
|
|
104
|
+
def last(sort_by = :id)
|
|
105
|
+
_to_model where.order(sort_by.to_sym, :desc).first
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get number of rows
|
|
109
|
+
#
|
|
110
|
+
# @return [Integer] number of rows in the table
|
|
111
|
+
def count
|
|
112
|
+
Query.new(@database, @table, nil, **@options).count
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get a page of results using keyset pagination.
|
|
116
|
+
#
|
|
117
|
+
# Sorting by a column other than :id requires a composite index on
|
|
118
|
+
# (sort_by, id) for seek performance — see README.
|
|
119
|
+
#
|
|
120
|
+
# @param cursor [*, nil] id of the last row from the previous page, or nil for the first page
|
|
121
|
+
# @param size [Integer] number of rows per page
|
|
122
|
+
# @param sort_by [Symbol] column to sort by
|
|
123
|
+
# @param sort_dir [Symbol] :asc or :desc
|
|
124
|
+
# @param where [Array] optional WHERE clause forwarded to Query#where
|
|
125
|
+
# @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
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Build aligned column names, $N placeholders and values from an attributes
|
|
133
|
+
# hash. Sorted by key so identical attributes always produce identical SQL -
|
|
134
|
+
# the prepared-statement name is a digest of the SQL.
|
|
135
|
+
#
|
|
136
|
+
# @param attributes [Hash] column => value
|
|
137
|
+
# @return [Array(Array, Array, Array)] sanitized columns, placeholders, values
|
|
138
|
+
def sql_params(attributes)
|
|
139
|
+
attrs = attributes.sort.to_h
|
|
140
|
+
[Utils.sanitize_columns(attrs.keys), (1..attrs.size).map { |i| "$#{i}" }, attrs.values]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Call #to_model on super class if defined
|
|
144
|
+
#
|
|
145
|
+
# @param obj [Hash]
|
|
146
|
+
# @return [Model, Hash] Model instance or a Hash
|
|
147
|
+
def _to_model(obj)
|
|
148
|
+
respond_to?(:to_model) ? obj && to_model(obj) : obj
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Call #to_models on super class if defined
|
|
152
|
+
#
|
|
153
|
+
# @param obj [Array]
|
|
154
|
+
# @return [Array] list of Model instance or a Hashes
|
|
155
|
+
def _to_models(obj)
|
|
156
|
+
respond_to?(:to_models) ? obj && to_models(obj) : obj
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class << self
|
|
160
|
+
def [](database, table, **options)
|
|
161
|
+
raise "Invalid table name: #{table}" unless table.to_s =~ /\A[a-z_][a-z0-9_]*\z/
|
|
162
|
+
|
|
163
|
+
mod = clone
|
|
164
|
+
mod.instance_variable_set("@database", database)
|
|
165
|
+
mod.instance_variable_set("@table", table)
|
|
166
|
+
mod.instance_variable_set("@options", options)
|
|
167
|
+
mod
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def extended(klass)
|
|
171
|
+
raise "Database table not specified" unless @table
|
|
172
|
+
|
|
173
|
+
klass.instance_variable_set("@database", @database)
|
|
174
|
+
klass.instance_variable_set("@table", @table)
|
|
175
|
+
klass.instance_variable_set("@options", @options)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
# end Eigen class
|
|
179
|
+
end
|
|
180
|
+
end
|
data/lib/pgi/db.rb
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "pg"
|
|
2
|
+
require "connection_pool"
|
|
3
|
+
require "pgi/connection"
|
|
4
|
+
|
|
5
|
+
module PGI
|
|
6
|
+
class DB
|
|
7
|
+
attr_reader :pool
|
|
8
|
+
|
|
9
|
+
# Create instance
|
|
10
|
+
#
|
|
11
|
+
# @param pool [ConnectionPool]
|
|
12
|
+
# @param logger [Logger]
|
|
13
|
+
# @param max_retries [Integer, Float] shared retry budget for lost connections and pool
|
|
14
|
+
# checkout timeouts - use Float::INFINITY to ride out arbitrarily long outages
|
|
15
|
+
# @param retry_wait [Numeric] seconds to sleep between reconnection attempts
|
|
16
|
+
def initialize(pool, logger, max_retries: 30, retry_wait: 2)
|
|
17
|
+
@pool = pool
|
|
18
|
+
@logger = logger
|
|
19
|
+
@max_retries = max_retries
|
|
20
|
+
@retry_wait = retry_wait
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.configure
|
|
24
|
+
@options = Struct.new(
|
|
25
|
+
:pool_size, :pool_timeout, :pg_conn_uri, :logger, :max_retries, :retry_wait
|
|
26
|
+
).new
|
|
27
|
+
|
|
28
|
+
yield @options
|
|
29
|
+
|
|
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
|
+
end
|
|
33
|
+
|
|
34
|
+
retry_options = { max_retries: @options.max_retries, retry_wait: @options.retry_wait }.compact
|
|
35
|
+
new(pool, @options.logger, **retry_options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# wrapper around ConnectionPool#with with auto-healing capabilities
|
|
39
|
+
#
|
|
40
|
+
# @yield PGI:Connection
|
|
41
|
+
def transaction
|
|
42
|
+
raise "Missing block" unless block_given?
|
|
43
|
+
|
|
44
|
+
with do |conn|
|
|
45
|
+
conn.transaction do |trans_conn|
|
|
46
|
+
yield Connection.new(conn: trans_conn, logger: @logger)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# wrapper around ConnectionPool#with with auto-healing capabilities
|
|
52
|
+
#
|
|
53
|
+
# Lost connections and pool checkout timeouts share the max_retries budget,
|
|
54
|
+
# so a genuinely stuck pool fails loudly instead of waiting forever.
|
|
55
|
+
#
|
|
56
|
+
# @yield PGI:Connection
|
|
57
|
+
def with
|
|
58
|
+
raise "Missing block" unless block_given?
|
|
59
|
+
|
|
60
|
+
retries = 0
|
|
61
|
+
begin
|
|
62
|
+
@pool.with do |conn| # rubocop:disable Style/ExplicitBlockArgument
|
|
63
|
+
yield conn
|
|
64
|
+
end
|
|
65
|
+
rescue PG::ConnectionBad, PG::UnableToSend => e
|
|
66
|
+
retries += 1
|
|
67
|
+
if retries > @max_retries
|
|
68
|
+
@logger&.error("DB connection was lost - unable to reconnect (#{e.class}: #{e.message})")
|
|
69
|
+
raise
|
|
70
|
+
end
|
|
71
|
+
@logger&.warn("DB connection was lost - reconnecting(#{retries}/#{@max_retries}) and retrying (#{e.class}: #{e.message})")
|
|
72
|
+
@pool.reload(&:close)
|
|
73
|
+
sleep @retry_wait
|
|
74
|
+
retry
|
|
75
|
+
rescue ConnectionPool::TimeoutError => e
|
|
76
|
+
retries += 1
|
|
77
|
+
if retries > @max_retries
|
|
78
|
+
@logger&.error("Timeout in checking out DB connection from pool - giving up (#{e.message})")
|
|
79
|
+
raise
|
|
80
|
+
end
|
|
81
|
+
@logger&.warn("Timeout in checking out DB connection from pool - retrying(#{retries}/#{@max_retries}) (#{e.message})")
|
|
82
|
+
retry
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def exec(sql)
|
|
87
|
+
with do |conn|
|
|
88
|
+
conn.exec(sql)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Pass the remainder of methods on to a PGI::Connection
|
|
93
|
+
#
|
|
94
|
+
# @See https://deveiate.org/code/pg/PG/Connection.html
|
|
95
|
+
def method_missing(name, ...)
|
|
96
|
+
with do |conn|
|
|
97
|
+
conn.__send__(name, ...)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def respond_to_missing?(name, include_private = false)
|
|
102
|
+
PG::Connection.method_defined?(name) || super
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module PGI
|
|
2
|
+
class SchemaMigrator
|
|
3
|
+
@config = Struct.new(:pg_conn, :migration_files, :seed_files).new
|
|
4
|
+
@migrations = Hash.new { |h, k| h[k] = {} }.merge(
|
|
5
|
+
# Default migration
|
|
6
|
+
0 => {
|
|
7
|
+
1 => "CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER," \
|
|
8
|
+
"created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP);",
|
|
9
|
+
-1 => "DROP TABLE schema_migrations;"
|
|
10
|
+
}
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def initialize(version)
|
|
14
|
+
raise "FATAL: version must be an integer > 0" unless version.is_a?(Integer) && version.positive?
|
|
15
|
+
raise "FATAL: Duplication migration version" if self.class.migrations.key?(version)
|
|
16
|
+
raise "FATAL: Broken migration ID sequence" unless version == self.class.migrations.keys.max + 1
|
|
17
|
+
|
|
18
|
+
@version = version
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def up
|
|
22
|
+
self.class.migrations[@version][1] = yield
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def down
|
|
26
|
+
self.class.migrations[@version][-1] = yield
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
attr_reader :config, :migrations
|
|
31
|
+
|
|
32
|
+
def configure
|
|
33
|
+
yield(config)
|
|
34
|
+
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def migrate!(version = nil)
|
|
39
|
+
raise "FATAL: version must be an integer >= 0" unless version.nil? || (version.is_a?(Integer) && version >= 0)
|
|
40
|
+
|
|
41
|
+
config.migration_files.sort.each { |file| require file }
|
|
42
|
+
|
|
43
|
+
raise "FATAL: Migration version does not exist" unless version.nil? || migrations.key?(version)
|
|
44
|
+
|
|
45
|
+
to_version = version || latest_migration
|
|
46
|
+
current = current_version
|
|
47
|
+
|
|
48
|
+
if current == to_version
|
|
49
|
+
puts "No migrations detected..."
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
config.pg_conn.transaction do
|
|
54
|
+
if to_version > current
|
|
55
|
+
((current + 1)..to_version).each do |v|
|
|
56
|
+
config.pg_conn.exec(migrations[v][1])
|
|
57
|
+
add_version(v)
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
current.downto(to_version + 1) do |v|
|
|
61
|
+
delete_version(v)
|
|
62
|
+
config.pg_conn.exec(migrations[v][-1])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def version(version)
|
|
69
|
+
yield(new(version))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def current_version
|
|
73
|
+
current = config.pg_conn.exec(<<~SQL).first
|
|
74
|
+
SELECT * FROM schema_migrations
|
|
75
|
+
ORDER BY version DESC LIMIT 1
|
|
76
|
+
SQL
|
|
77
|
+
|
|
78
|
+
(current && current["version"].to_i) || 0
|
|
79
|
+
rescue PG::UndefinedTable => e
|
|
80
|
+
raise unless e.message =~ /relation "schema_migrations" does not exist/
|
|
81
|
+
|
|
82
|
+
-1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def destroy!
|
|
86
|
+
config.pg_conn.exec(<<~SQL)
|
|
87
|
+
DO $$ DECLARE
|
|
88
|
+
r RECORD;
|
|
89
|
+
BEGIN
|
|
90
|
+
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP
|
|
91
|
+
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
|
|
92
|
+
END LOOP;
|
|
93
|
+
FOR r IN (SELECT DISTINCT typname FROM pg_type INNER JOIN pg_enum ON pg_enum.enumtypid = pg_type.oid) LOOP
|
|
94
|
+
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
|
95
|
+
END LOOP;
|
|
96
|
+
END $$;
|
|
97
|
+
SQL
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def latest_migration
|
|
103
|
+
migrations.keys.max || -1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def add_version(version)
|
|
107
|
+
config.pg_conn.exec_params(<<~SQL, [version])
|
|
108
|
+
INSERT INTO schema_migrations
|
|
109
|
+
(version) VALUES ($1)
|
|
110
|
+
SQL
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def delete_version(version)
|
|
114
|
+
config.pg_conn.exec_params(<<~SQL, [version])
|
|
115
|
+
DELETE FROM schema_migrations
|
|
116
|
+
WHERE version = $1
|
|
117
|
+
SQL
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/pgi/tasks.rake
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
namespace :db do
|
|
2
|
+
def current_version
|
|
3
|
+
PGI::SchemaMigrator.current_version
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
task :migration_env do
|
|
7
|
+
require "pgi/schema_migrator"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc "Prints current schema version"
|
|
11
|
+
task version: [:migration_env] do
|
|
12
|
+
puts "Schema Version: #{current_version}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "Perform migration up to latest migration available"
|
|
16
|
+
task migrate: [:migration_env] do
|
|
17
|
+
PGI::SchemaMigrator.migrate!
|
|
18
|
+
Rake::Task["db:version"].execute
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# TODO: Don't rollback to version = 0 by default
|
|
22
|
+
desc "Perform rollback to specified target or full rollback as default"
|
|
23
|
+
task :rollback, [:target] => [:migration_env] do |_, args|
|
|
24
|
+
args.with_defaults(target: 0)
|
|
25
|
+
|
|
26
|
+
if args.target.to_i < current_version
|
|
27
|
+
puts "WARNING: You are about to rollback migration from version #{current_version} to #{args.target}"
|
|
28
|
+
5.downto(1) do |i|
|
|
29
|
+
print "\rI'm giving you #{i} seconds to regret and abort", ".. "
|
|
30
|
+
sleep 1
|
|
31
|
+
end
|
|
32
|
+
puts
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
PGI::SchemaMigrator.migrate! args[:target].to_i
|
|
36
|
+
Rake::Task["db:version"].execute
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "Destroy all tables with and go back to empty DB"
|
|
40
|
+
task destroy: [:migration_env] do
|
|
41
|
+
unless %w[development test staging ci].include?(ENV.fetch("RACK_ENV", nil))
|
|
42
|
+
warn "Destroy not allowed for environment #{ENV.fetch("RACK_ENV", nil).inspect}"
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
puts "Destroying all tables..."
|
|
47
|
+
PGI::SchemaMigrator.destroy!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
desc "Seed database"
|
|
51
|
+
task seed: [:migration_env] do
|
|
52
|
+
puts "Seeding database with test data..."
|
|
53
|
+
PGI::SchemaMigrator.config.seed_files.each { |file| require file }
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/pgi/version.rb
ADDED
data/lib/pgi.rb
ADDED
data/pgi.gemspec
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
lib = File.expand_path("lib", __dir__)
|
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
3
|
+
require "pgi/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |gem|
|
|
6
|
+
gem.name = "pgi"
|
|
7
|
+
gem.version = PGI::VERSION
|
|
8
|
+
gem.authors = ["Coherify"]
|
|
9
|
+
gem.email = ["hello@coherify.net"]
|
|
10
|
+
gem.description = "Simple and convenient interface for PostgreSQL with a few enhancements"
|
|
11
|
+
gem.summary = "Simple and convenient interface for PostgreSQL with a few enhancements"
|
|
12
|
+
gem.homepage = "https://github.com/coherify/pgi"
|
|
13
|
+
gem.license = "MIT"
|
|
14
|
+
|
|
15
|
+
gem.required_ruby_version = ">= 3.4.0"
|
|
16
|
+
|
|
17
|
+
gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
|
|
18
|
+
gem.require_paths = ["lib"]
|
|
19
|
+
gem.files = Dir["lib/**/*", "CHANGELOG.md", "LICENSE", "README.md", "VERSION", "pgi.gemspec"]
|
|
20
|
+
|
|
21
|
+
gem.add_dependency "connection_pool", "~> 2.4"
|
|
22
|
+
gem.add_dependency "pg", "~> 1.5"
|
|
23
|
+
|
|
24
|
+
gem.metadata["rubygems_mfa_required"] = "true"
|
|
25
|
+
gem.metadata["source_code_uri"] = "https://github.com/coherify/pgi"
|
|
26
|
+
gem.metadata["changelog_uri"] = "https://github.com/coherify/pgi/blob/main/CHANGELOG.md"
|
|
27
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pgi
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Coherify
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: connection_pool
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.4'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.4'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: pg
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.5'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.5'
|
|
40
|
+
description: Simple and convenient interface for PostgreSQL with a few enhancements
|
|
41
|
+
email:
|
|
42
|
+
- hello@coherify.net
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- CHANGELOG.md
|
|
48
|
+
- LICENSE
|
|
49
|
+
- README.md
|
|
50
|
+
- VERSION
|
|
51
|
+
- lib/pgi.rb
|
|
52
|
+
- lib/pgi/connection.rb
|
|
53
|
+
- lib/pgi/dataset.rb
|
|
54
|
+
- lib/pgi/dataset/query.rb
|
|
55
|
+
- lib/pgi/dataset/utils.rb
|
|
56
|
+
- lib/pgi/db.rb
|
|
57
|
+
- lib/pgi/schema_migrator.rb
|
|
58
|
+
- lib/pgi/tasks.rake
|
|
59
|
+
- lib/pgi/version.rb
|
|
60
|
+
- pgi.gemspec
|
|
61
|
+
homepage: https://github.com/coherify/pgi
|
|
62
|
+
licenses:
|
|
63
|
+
- MIT
|
|
64
|
+
metadata:
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
source_code_uri: https://github.com/coherify/pgi
|
|
67
|
+
changelog_uri: https://github.com/coherify/pgi/blob/main/CHANGELOG.md
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: 3.4.0
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 4.0.14
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Simple and convenient interface for PostgreSQL with a few enhancements
|
|
85
|
+
test_files: []
|