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 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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module PGI
2
+ VERSION = File.read("#{__dir__}/../../VERSION").split("\n").first&.freeze
3
+ end
data/lib/pgi.rb ADDED
@@ -0,0 +1,4 @@
1
+ module PGI
2
+ autoload(:Dataset, "pgi/dataset")
3
+ autoload(:DB, "pgi/db")
4
+ end
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: []