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.
@@ -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