dorm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/README.md +226 -0
- data/Rakefile +92 -0
- data/examples/connection_pool_example.rb +88 -0
- data/examples/query_builder_examples.rb +202 -0
- data/lib/dorm/connection_pool.rb +218 -0
- data/lib/dorm/database.rb +142 -0
- data/lib/dorm/functional_helpers.rb +141 -0
- data/lib/dorm/query_builder.rb +434 -0
- data/lib/dorm/repository.rb +338 -0
- data/lib/dorm/result.rb +77 -0
- data/lib/dorm/version.rb +5 -0
- data/lib/dorm.rb +25 -0
- data/sig/Dorm.rbs +4 -0
- metadata +159 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
module Dorm
|
|
7
|
+
class ConnectionPool
|
|
8
|
+
include MonitorMixin
|
|
9
|
+
|
|
10
|
+
class PoolExhaustedError < StandardError; end
|
|
11
|
+
class ConnectionTimeoutError < StandardError; end
|
|
12
|
+
|
|
13
|
+
Connection = Data.define(:raw_connection, :created_at, :last_used_at) do
|
|
14
|
+
def expired?(max_age)
|
|
15
|
+
Time.now - created_at > max_age
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def stale?(max_idle)
|
|
19
|
+
Time.now - last_used_at > max_idle
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def touch!
|
|
23
|
+
with(last_used_at: Time.now)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(
|
|
28
|
+
size: 5,
|
|
29
|
+
timeout: 5,
|
|
30
|
+
max_age: 3600, # 1 hour
|
|
31
|
+
max_idle: 300, # 5 minutes
|
|
32
|
+
reap_frequency: 60 # 1 minute
|
|
33
|
+
)
|
|
34
|
+
super()
|
|
35
|
+
|
|
36
|
+
@size = size
|
|
37
|
+
@timeout = timeout
|
|
38
|
+
@max_age = max_age
|
|
39
|
+
@max_idle = max_idle
|
|
40
|
+
@reap_frequency = reap_frequency
|
|
41
|
+
|
|
42
|
+
@available = []
|
|
43
|
+
@connections = {}
|
|
44
|
+
@connection_factory = nil
|
|
45
|
+
@last_reap = Time.now
|
|
46
|
+
|
|
47
|
+
start_reaper_thread if @reap_frequency > 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def configure_factory(&block)
|
|
51
|
+
@connection_factory = block
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def with_connection(&block)
|
|
55
|
+
connection = checkout_connection
|
|
56
|
+
begin
|
|
57
|
+
result = block.call(connection.raw_connection)
|
|
58
|
+
result
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
# If connection is bad, don't return it to pool
|
|
61
|
+
remove_connection(connection)
|
|
62
|
+
raise
|
|
63
|
+
ensure
|
|
64
|
+
# Always try to check the connection back in (if it wasn't removed)
|
|
65
|
+
if connection && @connections.key?(connection.object_id)
|
|
66
|
+
updated_connection = connection.touch!
|
|
67
|
+
synchronize do
|
|
68
|
+
# Replace the connection in the hash with the updated version
|
|
69
|
+
@connections[updated_connection.object_id] = updated_connection
|
|
70
|
+
@connections.delete(connection.object_id) unless connection.equal?(updated_connection)
|
|
71
|
+
end
|
|
72
|
+
checkin_connection(updated_connection)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def size
|
|
78
|
+
synchronize { @connections.size }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def available_count
|
|
82
|
+
synchronize { @available.size }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def checked_out_count
|
|
86
|
+
synchronize { @connections.size - @available.size }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def disconnect!
|
|
90
|
+
synchronize do
|
|
91
|
+
@connections.each_value do |conn|
|
|
92
|
+
close_connection(conn)
|
|
93
|
+
end
|
|
94
|
+
@connections.clear
|
|
95
|
+
@available.clear
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Manual cleanup of expired/stale connections
|
|
100
|
+
def reap_connections!
|
|
101
|
+
synchronize do
|
|
102
|
+
stale_connections = @available.select do |conn|
|
|
103
|
+
conn.expired?(@max_age) || conn.stale?(@max_idle)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
stale_connections.each do |conn|
|
|
107
|
+
@available.delete(conn)
|
|
108
|
+
@connections.delete(conn.object_id)
|
|
109
|
+
close_connection(conn)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@last_reap = Time.now
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def checkout_connection
|
|
119
|
+
synchronize do
|
|
120
|
+
# Reap periodically
|
|
121
|
+
reap_connections! if should_reap?
|
|
122
|
+
|
|
123
|
+
# Try to get an available connection first
|
|
124
|
+
conn = @available.pop
|
|
125
|
+
|
|
126
|
+
# If no available connection, try to create a new one
|
|
127
|
+
conn ||= create_connection
|
|
128
|
+
|
|
129
|
+
# If still no connection, we need to wait for one to become available
|
|
130
|
+
conn = wait_for_connection if conn.nil?
|
|
131
|
+
|
|
132
|
+
raise PoolExhaustedError, 'Could not obtain connection' if conn.nil?
|
|
133
|
+
|
|
134
|
+
# Mark connection as checked out
|
|
135
|
+
@connections[conn.object_id] = conn
|
|
136
|
+
conn
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def checkin_connection(connection)
|
|
141
|
+
synchronize do
|
|
142
|
+
# Only add back to available if it's still in our connections hash
|
|
143
|
+
@available.push(connection) if @connections.key?(connection.object_id) && !@available.include?(connection)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def remove_connection(connection)
|
|
148
|
+
synchronize do
|
|
149
|
+
@connections.delete(connection.object_id)
|
|
150
|
+
@available.delete(connection)
|
|
151
|
+
close_connection(connection)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def create_connection
|
|
156
|
+
return nil unless @connection_factory
|
|
157
|
+
return nil if @connections.size >= @size
|
|
158
|
+
|
|
159
|
+
raw_conn = @connection_factory.call
|
|
160
|
+
now = Time.now
|
|
161
|
+
Connection.new(
|
|
162
|
+
raw_connection: raw_conn,
|
|
163
|
+
created_at: now,
|
|
164
|
+
last_used_at: now
|
|
165
|
+
)
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
raise Dorm::ConfigurationError, "Failed to create database connection: #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def wait_for_connection
|
|
171
|
+
deadline = Time.now + @timeout
|
|
172
|
+
|
|
173
|
+
loop do
|
|
174
|
+
# Check if we've exceeded the timeout first
|
|
175
|
+
raise ConnectionTimeoutError, 'Timeout waiting for database connection' if Time.now >= deadline
|
|
176
|
+
|
|
177
|
+
synchronize do
|
|
178
|
+
# Check if a connection became available
|
|
179
|
+
conn = @available.pop
|
|
180
|
+
return conn if conn
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Small sleep to avoid busy waiting
|
|
184
|
+
sleep(0.001) # 1ms
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def close_connection(connection)
|
|
189
|
+
case connection.raw_connection
|
|
190
|
+
when ->(conn) { conn.respond_to?(:close) }
|
|
191
|
+
connection.raw_connection.close
|
|
192
|
+
when ->(conn) { conn.respond_to?(:finish) } # PG connection
|
|
193
|
+
connection.raw_connection.finish
|
|
194
|
+
end
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
# Log error but don't raise - we're cleaning up
|
|
197
|
+
puts "Warning: Error closing connection: #{e.message}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def should_reap?
|
|
201
|
+
Time.now - @last_reap > @reap_frequency
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def start_reaper_thread
|
|
205
|
+
@reaper_thread = Thread.new do
|
|
206
|
+
loop do
|
|
207
|
+
sleep(@reap_frequency)
|
|
208
|
+
begin
|
|
209
|
+
reap_connections!
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
puts "Warning: Error in connection reaper: #{e.message}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
@reaper_thread.name = 'Dorm Connection Pool Reaper'
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'connection_pool'
|
|
4
|
+
|
|
5
|
+
module Dorm
|
|
6
|
+
module Database
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
attr_reader :adapter, :pool
|
|
10
|
+
|
|
11
|
+
def configure(adapter:, pool_size: 5, pool_timeout: 5, **options)
|
|
12
|
+
@adapter = adapter.to_sym
|
|
13
|
+
@connection_options = options
|
|
14
|
+
@pool_size = pool_size
|
|
15
|
+
@pool_timeout = pool_timeout
|
|
16
|
+
|
|
17
|
+
# Disconnect existing pool if any
|
|
18
|
+
@pool&.disconnect!
|
|
19
|
+
|
|
20
|
+
# Create new pool
|
|
21
|
+
@pool = ConnectionPool.new(
|
|
22
|
+
size: pool_size,
|
|
23
|
+
timeout: pool_timeout,
|
|
24
|
+
max_age: options[:max_connection_age] || 3600,
|
|
25
|
+
max_idle: options[:max_idle_time] || 300,
|
|
26
|
+
reap_frequency: options[:reap_frequency] || 60
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Configure the connection factory
|
|
30
|
+
@pool.configure_factory { establish_connection }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def query(sql, params = [])
|
|
34
|
+
ensure_configured!
|
|
35
|
+
|
|
36
|
+
@pool.with_connection do |connection|
|
|
37
|
+
execute_query(connection, sql, params)
|
|
38
|
+
end
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
raise Error, "Database query failed: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def transaction(&block)
|
|
44
|
+
ensure_configured!
|
|
45
|
+
|
|
46
|
+
@pool.with_connection do |connection|
|
|
47
|
+
execute_transaction(connection, &block)
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
raise Error, "Transaction failed: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Pool statistics for monitoring
|
|
54
|
+
def pool_stats
|
|
55
|
+
return {} unless @pool
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
size: @pool.size,
|
|
59
|
+
available: @pool.available_count,
|
|
60
|
+
checked_out: @pool.checked_out_count,
|
|
61
|
+
adapter: @adapter
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Disconnect all connections (useful for testing or shutdown)
|
|
66
|
+
def disconnect!
|
|
67
|
+
@pool&.disconnect!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def ensure_configured!
|
|
73
|
+
return if @pool && @adapter
|
|
74
|
+
|
|
75
|
+
raise ConfigurationError, 'Database not configured. Call Dorm.configure first.'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def establish_connection
|
|
79
|
+
case @adapter
|
|
80
|
+
when :postgresql
|
|
81
|
+
require 'pg'
|
|
82
|
+
conn = PG.connect(@connection_options)
|
|
83
|
+
# Set some reasonable defaults for pooled connections
|
|
84
|
+
conn.exec("SET application_name = 'Dorm'") if conn.respond_to?(:exec)
|
|
85
|
+
conn
|
|
86
|
+
when :sqlite3
|
|
87
|
+
require 'sqlite3'
|
|
88
|
+
db = SQLite3::Database.new(@connection_options[:database] || ':memory:')
|
|
89
|
+
# Return results as hashes instead of arrays
|
|
90
|
+
db.results_as_hash = true
|
|
91
|
+
# Enable foreign keys and other useful pragmas
|
|
92
|
+
db.execute('PRAGMA foreign_keys = ON')
|
|
93
|
+
db.execute('PRAGMA journal_mode = WAL') unless @connection_options[:database] == ':memory:'
|
|
94
|
+
db
|
|
95
|
+
else
|
|
96
|
+
raise ConfigurationError, "Unsupported database adapter: #{@adapter}"
|
|
97
|
+
end
|
|
98
|
+
rescue LoadError => e
|
|
99
|
+
raise ConfigurationError, "Database adapter gem not found: #{e.message}"
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
raise ConfigurationError, "Failed to establish database connection: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def execute_query(connection, sql, params)
|
|
105
|
+
case @adapter
|
|
106
|
+
when :postgresql
|
|
107
|
+
result = connection.exec_params(sql, params)
|
|
108
|
+
# Convert PG::Result to array of hashes
|
|
109
|
+
result.map { |row| row }
|
|
110
|
+
when :sqlite3
|
|
111
|
+
if params.empty?
|
|
112
|
+
connection.execute(sql)
|
|
113
|
+
else
|
|
114
|
+
connection.execute(sql, params)
|
|
115
|
+
end
|
|
116
|
+
else
|
|
117
|
+
raise ConfigurationError, "Unsupported database adapter: #{@adapter}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def execute_transaction(connection, &block)
|
|
122
|
+
case @adapter
|
|
123
|
+
when :postgresql
|
|
124
|
+
connection.transaction(&block)
|
|
125
|
+
when :sqlite3
|
|
126
|
+
# SQLite3's transaction method might not work as expected in all cases
|
|
127
|
+
# Let's be more explicit about transaction handling
|
|
128
|
+
connection.execute('BEGIN TRANSACTION')
|
|
129
|
+
begin
|
|
130
|
+
result = block.call(connection)
|
|
131
|
+
connection.execute('COMMIT')
|
|
132
|
+
result
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
connection.execute('ROLLBACK')
|
|
135
|
+
raise
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
raise ConfigurationError, "Unsupported database adapter: #{@adapter}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dorm
|
|
4
|
+
# Functional composition helpers - inspired by Clojure
|
|
5
|
+
module FunctionalHelpers
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Pipe value through a series of functions
|
|
9
|
+
# pipe(value, f1, f2, f3) equivalent to f3(f2(f1(value)))
|
|
10
|
+
def pipe(value, *functions)
|
|
11
|
+
functions.reduce(value) { |v, f| f.call(v) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Compose functions into a single function
|
|
15
|
+
# comp(f1, f2, f3) returns ->(x) { f1(f2(f3(x))) }
|
|
16
|
+
def comp(*functions)
|
|
17
|
+
->(x) { functions.reverse.reduce(x) { |v, f| f.call(v) } }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Partial application - fix some arguments
|
|
21
|
+
# partial(method(:add), 5) returns ->(x) { add(5, x) }
|
|
22
|
+
def partial(func, *args)
|
|
23
|
+
# ->(x) { func.call(*args, x) }
|
|
24
|
+
if [method(:filter), method(:map_over), method(:take)].include?(func)
|
|
25
|
+
->(x) { func.call(x, *args) }
|
|
26
|
+
else
|
|
27
|
+
->(x) { func.call(*args, x) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Filter collection with predicate
|
|
32
|
+
def filter(collection, predicate)
|
|
33
|
+
collection.select(&predicate)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Map over collection
|
|
37
|
+
def map_over(collection, transform)
|
|
38
|
+
collection.map(&transform)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reduce collection
|
|
42
|
+
def reduce_with(collection, initial, reducer)
|
|
43
|
+
collection.reduce(initial, &reducer)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Find first element matching predicate
|
|
47
|
+
def find_first(collection, predicate)
|
|
48
|
+
collection.find(&predicate)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Take first n elements
|
|
52
|
+
def take(collection, n)
|
|
53
|
+
collection.take(n)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Drop first n elements
|
|
57
|
+
def drop(collection, n)
|
|
58
|
+
collection.drop(n)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Group by a function result
|
|
62
|
+
def group_by_fn(collection, grouper)
|
|
63
|
+
collection.group_by(&grouper)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Sort by a function result
|
|
67
|
+
def sort_by_fn(collection, sorter)
|
|
68
|
+
collection.sort_by(&sorter)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Apply function if condition is true, otherwise return original value
|
|
72
|
+
def apply_if(value, condition, func)
|
|
73
|
+
condition ? func.call(value) : value
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Thread-first macro simulation (Clojure's ->)
|
|
77
|
+
# thread_first(x, f1, f2, f3) equivalent to f3(f2(f1(x)))
|
|
78
|
+
def thread_first(value, *functions)
|
|
79
|
+
pipe(value, *functions)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Thread-last macro simulation (Clojure's ->>)
|
|
83
|
+
# thread_last(x, f1, f2, f3) equivalent to f3(f2(f1(x)))
|
|
84
|
+
# Useful when you want the value to be the last argument
|
|
85
|
+
def thread_last(value, *functions)
|
|
86
|
+
functions.reduce(value) do |v, f|
|
|
87
|
+
if f.respond_to?(:curry)
|
|
88
|
+
f.curry.call(v)
|
|
89
|
+
else
|
|
90
|
+
f.call(v)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Juxt - apply multiple functions to same value, return array of results
|
|
96
|
+
# juxt(f1, f2, f3) returns ->(x) { [f1(x), f2(x), f3(x)] }
|
|
97
|
+
def juxt(*functions)
|
|
98
|
+
->(x) { functions.map { |f| f.call(x) } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Maybe monad helpers for dealing with nils
|
|
102
|
+
def maybe(value)
|
|
103
|
+
value.nil? ? None.new : Some.new(value)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
Some = Data.define(:value) do
|
|
107
|
+
def bind(&block)
|
|
108
|
+
result = block.call(value)
|
|
109
|
+
result.is_a?(Some) || result.is_a?(None) ? result : Some.new(result)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def map(&block)
|
|
113
|
+
Some.new(block.call(value))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def value_or(_default)
|
|
117
|
+
value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def some? = true
|
|
121
|
+
def none? = false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
None = Data.define do
|
|
125
|
+
def bind(&block)
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def map(&block)
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def value_or(default)
|
|
134
|
+
default
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def some? = false
|
|
138
|
+
def none? = true
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|