rooq 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.
data/exe/rooq ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/rooq"
5
+ require_relative "../lib/rooq/cli"
6
+
7
+ exit Rooq::CLI.new(ARGV).run
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rooq
4
+ module Adapters
5
+ module PostgreSQL
6
+ # A simple connection pool for PostgreSQL connections.
7
+ # For production use, consider using a more robust pool like connection_pool gem.
8
+ #
9
+ # @example Basic usage
10
+ # pool = Rooq::Adapters::PostgreSQL::ConnectionPool.new(size: 5) do
11
+ # PG.connect(dbname: 'myapp', host: 'localhost')
12
+ # end
13
+ #
14
+ # ctx = Rooq::Context.using_pool(pool)
15
+ # # ... execute queries ...
16
+ # pool.shutdown
17
+ #
18
+ # @example With timeout
19
+ # pool = Rooq::Adapters::PostgreSQL::ConnectionPool.new(size: 10, timeout: 5) do
20
+ # PG.connect(connection_string)
21
+ # end
22
+ class ConnectionPool < Rooq::ConnectionPool
23
+ class TimeoutError < Rooq::Error; end
24
+
25
+ attr_reader :size
26
+
27
+ # Create a new connection pool.
28
+ # @param size [Integer] the maximum number of connections
29
+ # @param timeout [Numeric] seconds to wait for a connection (nil = wait forever)
30
+ # @yield the block that creates a new connection
31
+ def initialize(size: 5, timeout: nil, &block)
32
+ super()
33
+ @size = size
34
+ @timeout = timeout
35
+ @create_connection = block
36
+ @mutex = Mutex.new
37
+ @condition = ConditionVariable.new
38
+ @available = []
39
+ @in_use = []
40
+ @shutdown = false
41
+
42
+ # Pre-create all connections
43
+ @size.times { @available << @create_connection.call }
44
+ end
45
+
46
+ # Check out a connection from the pool.
47
+ # @return [PG::Connection] a database connection
48
+ # @raise [TimeoutError] if timeout expires while waiting
49
+ def checkout
50
+ @mutex.synchronize do
51
+ raise Error, "Pool has been shut down" if @shutdown
52
+
53
+ deadline = @timeout ? Time.now + @timeout : nil
54
+
55
+ while @available.empty?
56
+ if deadline
57
+ remaining = deadline - Time.now
58
+ raise TimeoutError, "Timed out waiting for connection" if remaining <= 0
59
+
60
+ @condition.wait(@mutex, remaining)
61
+ else
62
+ @condition.wait(@mutex)
63
+ end
64
+
65
+ raise Error, "Pool has been shut down" if @shutdown
66
+ end
67
+
68
+ connection = @available.pop
69
+ @in_use << connection
70
+ connection
71
+ end
72
+ end
73
+
74
+ # Check in a connection back to the pool.
75
+ # @param connection [PG::Connection] the connection to return
76
+ def checkin(connection)
77
+ @mutex.synchronize do
78
+ @in_use.delete(connection)
79
+ @available << connection unless @shutdown
80
+ @condition.signal
81
+ end
82
+ end
83
+
84
+ # @return [Integer] number of available connections
85
+ def available
86
+ @mutex.synchronize { @available.length }
87
+ end
88
+
89
+ # Shutdown the pool and close all connections.
90
+ def shutdown
91
+ @mutex.synchronize do
92
+ @shutdown = true
93
+ @available.each { |conn| conn.close if conn.respond_to?(:close) }
94
+ @in_use.each { |conn| conn.close if conn.respond_to?(:close) }
95
+ @available.clear
96
+ @in_use.clear
97
+ @condition.broadcast
98
+ end
99
+ end
100
+ end
101
+
102
+ # Create a Context configured for PostgreSQL.
103
+ # @param connection [PG::Connection] a single connection
104
+ # @return [Rooq::Context]
105
+ def self.context(connection)
106
+ Rooq::Context.using(connection, dialect: Rooq::Dialect::PostgreSQL.new)
107
+ end
108
+
109
+ # Create a Context with a connection pool.
110
+ # @param pool [ConnectionPool] a connection pool
111
+ # @return [Rooq::Context]
112
+ def self.pooled_context(pool)
113
+ Rooq::Context.using_pool(pool, dialect: Rooq::Dialect::PostgreSQL.new)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapters/postgresql"
data/lib/rooq/cli.rb ADDED
@@ -0,0 +1,230 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "optparse"
6
+ require "sorbet-runtime"
7
+
8
+ module Rooq
9
+ class CLI
10
+ extend T::Sig
11
+
12
+ sig { params(args: T::Array[String]).void }
13
+ def initialize(args)
14
+ @args = args
15
+ @options = T.let({
16
+ schema: "public",
17
+ output: nil,
18
+ stdout: false,
19
+ typed: true,
20
+ namespace: "Schema",
21
+ database: nil,
22
+ host: "localhost",
23
+ port: 5432,
24
+ username: nil,
25
+ password: nil
26
+ }, T::Hash[Symbol, T.untyped])
27
+ end
28
+
29
+ sig { returns(Integer) }
30
+ def run
31
+ parse_options!
32
+
33
+ case @args.first
34
+ when "generate", "gen", "g"
35
+ generate_command
36
+ when "version", "-v", "--version"
37
+ version_command
38
+ when "help", "-h", "--help", nil
39
+ help_command
40
+ else
41
+ $stderr.puts "Unknown command: #{@args.first}"
42
+ $stderr.puts "Run 'rooq help' for usage information."
43
+ 1
44
+ end
45
+ rescue StandardError => e
46
+ $stderr.puts "Error: #{e.message}"
47
+ $stderr.puts e.backtrace.first(5).join("\n") if ENV["DEBUG"]
48
+ 1
49
+ end
50
+
51
+ private
52
+
53
+ sig { void }
54
+ def parse_options!
55
+ parser = OptionParser.new do |opts|
56
+ opts.banner = "Usage: rooq <command> [options]"
57
+
58
+ opts.on("-d", "--database DATABASE", "Database name (required)") do |v|
59
+ @options[:database] = v
60
+ end
61
+
62
+ opts.on("-h", "--host HOST", "Database host (default: localhost)") do |v|
63
+ @options[:host] = v
64
+ end
65
+
66
+ opts.on("-p", "--port PORT", Integer, "Database port (default: 5432)") do |v|
67
+ @options[:port] = v
68
+ end
69
+
70
+ opts.on("-U", "--username USERNAME", "Database username") do |v|
71
+ @options[:username] = v
72
+ end
73
+
74
+ opts.on("-W", "--password PASSWORD", "Database password") do |v|
75
+ @options[:password] = v
76
+ end
77
+
78
+ opts.on("-s", "--schema SCHEMA", "Schema name (default: public)") do |v|
79
+ @options[:schema] = v
80
+ end
81
+
82
+ opts.on("-o", "--output FILE", "Output file (default: lib/schema.rb)") do |v|
83
+ @options[:output] = v
84
+ end
85
+
86
+ opts.on("-n", "--namespace NAMESPACE", "Module namespace (default: Schema)") do |v|
87
+ @options[:namespace] = v
88
+ end
89
+
90
+ opts.on("--stdout", "Print to stdout instead of file") do
91
+ @options[:stdout] = true
92
+ end
93
+
94
+ opts.on("--[no-]typed", "Generate Sorbet types (default: true)") do |v|
95
+ @options[:typed] = v
96
+ end
97
+
98
+ opts.on("--help", "Show this help message") do
99
+ puts opts
100
+ exit 0
101
+ end
102
+ end
103
+
104
+ parser.parse!(@args)
105
+ end
106
+
107
+ sig { returns(Integer) }
108
+ def generate_command
109
+ @args.shift # Remove the "generate" command
110
+
111
+ unless @options[:database]
112
+ $stderr.puts "Error: Database name is required (-d DATABASE)"
113
+ $stderr.puts "Run 'rooq help' for usage information."
114
+ return 1
115
+ end
116
+
117
+ require "pg"
118
+
119
+ connection = connect_to_database
120
+ introspector = Generator::Introspector.new(connection)
121
+ schema_info = introspector.introspect_schema(schema: @options[:schema])
122
+
123
+ generator = Generator::CodeGenerator.new(
124
+ schema_info,
125
+ typed: @options[:typed],
126
+ namespace: @options[:namespace]
127
+ )
128
+ code = generator.generate
129
+
130
+ if @options[:stdout]
131
+ puts code
132
+ else
133
+ output_file = @options[:output] || default_output_file
134
+ FileUtils.mkdir_p(File.dirname(output_file))
135
+ File.write(output_file, code)
136
+ puts "Generated #{output_file}"
137
+ end
138
+
139
+ connection.close
140
+ 0
141
+ end
142
+
143
+ sig { returns(String) }
144
+ def default_output_file
145
+ # Convert namespace to filename: MyApp::Schema -> my_app/schema.rb
146
+ namespace = @options[:namespace].to_s
147
+ filename = namespace.gsub("::", "/").gsub(/([a-z])([A-Z])/, '\1_\2').downcase
148
+ "lib/#{filename}.rb"
149
+ end
150
+
151
+ sig { returns(T.untyped) }
152
+ def connect_to_database
153
+ connection_params = {
154
+ dbname: @options[:database],
155
+ host: @options[:host],
156
+ port: @options[:port]
157
+ }
158
+
159
+ connection_params[:user] = @options[:username] if @options[:username]
160
+ connection_params[:password] = @options[:password] if @options[:password]
161
+
162
+ # Also check environment variables
163
+ connection_params[:user] ||= ENV["PGUSER"]
164
+ connection_params[:password] ||= ENV["PGPASSWORD"]
165
+ connection_params[:host] = ENV["PGHOST"] if ENV["PGHOST"]
166
+ connection_params[:port] = ENV["PGPORT"].to_i if ENV["PGPORT"]
167
+
168
+ PG.connect(connection_params)
169
+ end
170
+
171
+ sig { returns(Integer) }
172
+ def version_command
173
+ puts "rooq #{Rooq::VERSION}"
174
+ 0
175
+ end
176
+
177
+ sig { returns(Integer) }
178
+ def help_command
179
+ puts <<~HELP
180
+ rOOQ - A jOOQ-inspired query builder for Ruby
181
+
182
+ Usage: rooq <command> [options]
183
+
184
+ Commands:
185
+ generate, gen, g Generate Ruby table definitions from database schema
186
+ version Show version
187
+ help Show this help message
188
+
189
+ Options for 'generate':
190
+ -d, --database DATABASE Database name (required)
191
+ -h, --host HOST Database host (default: localhost)
192
+ -p, --port PORT Database port (default: 5432)
193
+ -U, --username USERNAME Database username
194
+ -W, --password PASSWORD Database password
195
+ -s, --schema SCHEMA Database schema (default: public)
196
+ -o, --output FILE Output file (default: lib/schema.rb)
197
+ -n, --namespace NAMESPACE Module namespace (default: Schema)
198
+ --stdout Print to stdout instead of file
199
+ --[no-]typed Generate Sorbet types (default: true)
200
+
201
+ Environment Variables:
202
+ PGHOST Default database host
203
+ PGPORT Default database port
204
+ PGUSER Default database username
205
+ PGPASSWORD Default database password
206
+
207
+ Examples:
208
+ # Generate schema to lib/schema.rb
209
+ rooq generate -d myapp_development
210
+
211
+ # Generate with custom namespace (writes to lib/my_app/db.rb)
212
+ rooq generate -d myapp_development -n MyApp::DB
213
+
214
+ # Generate to custom file
215
+ rooq generate -d myapp_development -o db/schema.rb
216
+
217
+ # Generate without Sorbet types
218
+ rooq generate -d myapp_development --no-typed
219
+
220
+ # Print to stdout instead of file
221
+ rooq generate -d myapp_development --stdout
222
+
223
+ # Connect to remote database
224
+ rooq generate -d myapp -h db.example.com -U postgres -W secret
225
+
226
+ HELP
227
+ 0
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,104 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Rooq
7
+ # Type alias for any condition type
8
+ AnyCondition = T.type_alias { T.any(Condition, CombinedCondition, ExistsCondition) }
9
+
10
+ class Condition
11
+ extend T::Sig
12
+
13
+ sig { returns(Expression) }
14
+ attr_reader :expression
15
+
16
+ sig { returns(Symbol) }
17
+ attr_reader :operator
18
+
19
+ sig { returns(T.untyped) }
20
+ attr_reader :value
21
+
22
+ sig { params(expression: Expression, operator: Symbol, value: T.untyped).void }
23
+ def initialize(expression, operator, value)
24
+ @expression = expression
25
+ @operator = operator
26
+ @value = value
27
+ freeze
28
+ end
29
+
30
+ # Backwards compatibility
31
+ sig { returns(Expression) }
32
+ def field
33
+ @expression
34
+ end
35
+
36
+ sig { params(other: AnyCondition).returns(CombinedCondition) }
37
+ def and(other)
38
+ CombinedCondition.new(:and, [self, other])
39
+ end
40
+
41
+ sig { params(other: AnyCondition).returns(CombinedCondition) }
42
+ def or(other)
43
+ CombinedCondition.new(:or, [self, other])
44
+ end
45
+ end
46
+
47
+ class CombinedCondition
48
+ extend T::Sig
49
+
50
+ sig { returns(Symbol) }
51
+ attr_reader :operator
52
+
53
+ sig { returns(T::Array[AnyCondition]) }
54
+ attr_reader :conditions
55
+
56
+ sig { params(operator: Symbol, conditions: T::Array[AnyCondition]).void }
57
+ def initialize(operator, conditions)
58
+ @operator = operator
59
+ @conditions = T.let(conditions.freeze, T::Array[AnyCondition])
60
+ freeze
61
+ end
62
+
63
+ sig { params(other: AnyCondition).returns(CombinedCondition) }
64
+ def and(other)
65
+ CombinedCondition.new(:and, [*@conditions, other])
66
+ end
67
+
68
+ sig { params(other: AnyCondition).returns(CombinedCondition) }
69
+ def or(other)
70
+ CombinedCondition.new(:or, [*@conditions, other])
71
+ end
72
+ end
73
+
74
+ # EXISTS condition
75
+ class ExistsCondition
76
+ extend T::Sig
77
+
78
+ sig { returns(DSL::SelectQuery) }
79
+ attr_reader :subquery
80
+
81
+ sig { returns(T::Boolean) }
82
+ attr_reader :negated
83
+
84
+ sig { params(subquery: DSL::SelectQuery, negated: T::Boolean).void }
85
+ def initialize(subquery, negated: false)
86
+ @subquery = subquery
87
+ @negated = negated
88
+ freeze
89
+ end
90
+ end
91
+
92
+ extend T::Sig
93
+
94
+ # Helper methods for conditions
95
+ sig { params(subquery: DSL::SelectQuery).returns(ExistsCondition) }
96
+ def self.exists(subquery)
97
+ ExistsCondition.new(subquery)
98
+ end
99
+
100
+ sig { params(subquery: DSL::SelectQuery).returns(ExistsCondition) }
101
+ def self.not_exists(subquery)
102
+ ExistsCondition.new(subquery, negated: true)
103
+ end
104
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rooq
4
+ # Configuration holds all settings for a Rooq context.
5
+ # Configurations are immutable - use #derive to create modified copies.
6
+ #
7
+ # Inspired by jOOQ's Configuration class.
8
+ # @see https://www.jooq.org/javadoc/latest/org.jooq/org/jooq/Configuration.html
9
+ class Configuration
10
+ attr_reader :connection_provider, :dialect
11
+
12
+ # Create a new configuration.
13
+ # @param connection_provider [ConnectionProvider] the connection provider
14
+ # @param dialect [Dialect::Base] the SQL dialect
15
+ def initialize(connection_provider: nil, dialect: nil)
16
+ @connection_provider = connection_provider
17
+ @dialect = dialect || Dialect::PostgreSQL.new
18
+ freeze
19
+ end
20
+
21
+ # Create a new configuration from a single connection.
22
+ # The connection lifecycle is managed externally.
23
+ # @param connection [Object] a database connection
24
+ # @param dialect [Dialect::Base] the SQL dialect
25
+ # @return [Configuration]
26
+ def self.from_connection(connection, dialect: nil)
27
+ new(
28
+ connection_provider: DefaultConnectionProvider.new(connection),
29
+ dialect: dialect
30
+ )
31
+ end
32
+
33
+ # Create a new configuration from a connection pool.
34
+ # Connections are acquired and released per query.
35
+ # @param pool [ConnectionPool] a connection pool
36
+ # @param dialect [Dialect::Base] the SQL dialect
37
+ # @return [Configuration]
38
+ def self.from_pool(pool, dialect: nil)
39
+ new(
40
+ connection_provider: PooledConnectionProvider.new(pool),
41
+ dialect: dialect
42
+ )
43
+ end
44
+
45
+ # Create a derived configuration with some settings overridden.
46
+ # @param connection_provider [ConnectionProvider] new connection provider (optional)
47
+ # @param dialect [Dialect::Base] new dialect (optional)
48
+ # @return [Configuration] a new configuration
49
+ def derive(connection_provider: nil, dialect: nil)
50
+ Configuration.new(
51
+ connection_provider: connection_provider || @connection_provider,
52
+ dialect: dialect || @dialect
53
+ )
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rooq
4
+ # Abstract interface for connection lifecycle management.
5
+ # Implementations control how connections are acquired and released.
6
+ #
7
+ # This is inspired by jOOQ's ConnectionProvider interface.
8
+ # @see https://www.jooq.org/javadoc/latest/org.jooq/org/jooq/ConnectionProvider.html
9
+ class ConnectionProvider
10
+ # Acquire a connection for query execution.
11
+ # @return [Object] a database connection
12
+ # @raise [NotImplementedError] if not implemented by subclass
13
+ def acquire
14
+ raise NotImplementedError, "#{self.class} must implement #acquire"
15
+ end
16
+
17
+ # Release a previously acquired connection.
18
+ # @param connection [Object] the connection to release
19
+ # @raise [NotImplementedError] if not implemented by subclass
20
+ def release(connection)
21
+ raise NotImplementedError, "#{self.class} must implement #release"
22
+ end
23
+
24
+ # Execute a block with an acquired connection, ensuring release.
25
+ # @yield [connection] the acquired connection
26
+ # @return [Object] the result of the block
27
+ def with_connection
28
+ connection = acquire
29
+ begin
30
+ yield connection
31
+ ensure
32
+ release(connection)
33
+ end
34
+ end
35
+ end
36
+
37
+ # A connection provider that wraps a single connection.
38
+ # The connection lifecycle is managed externally by the caller.
39
+ # Release is a no-op - the connection stays open until closed externally.
40
+ #
41
+ # Use this when you want to control transactions and connection lifecycle yourself.
42
+ class DefaultConnectionProvider < ConnectionProvider
43
+ attr_reader :connection
44
+
45
+ # @param connection [Object] the connection to wrap
46
+ def initialize(connection)
47
+ super()
48
+ @connection = connection
49
+ end
50
+
51
+ # Always returns the same connection instance.
52
+ # @return [Object] the wrapped connection
53
+ def acquire
54
+ @connection
55
+ end
56
+
57
+ # No-op - the connection lifecycle is managed externally.
58
+ # @param connection [Object] the connection (ignored)
59
+ def release(connection)
60
+ # No-op: connection lifecycle is managed externally
61
+ end
62
+ end
63
+
64
+ # A connection provider that acquires connections from a pool.
65
+ # Connections are returned to the pool after each query.
66
+ #
67
+ # The pool must respond to #checkout (or #acquire) and optionally #checkin.
68
+ # If the pool doesn't respond to #checkin, the connection's #close method is called.
69
+ class PooledConnectionProvider < ConnectionProvider
70
+ attr_reader :pool
71
+
72
+ # @param pool [ConnectionPool] a connection pool
73
+ def initialize(pool)
74
+ super()
75
+ @pool = pool
76
+ end
77
+
78
+ # Acquire a connection from the pool.
79
+ # @return [Object] a database connection
80
+ def acquire
81
+ if @pool.respond_to?(:checkout)
82
+ @pool.checkout
83
+ elsif @pool.respond_to?(:acquire)
84
+ @pool.acquire
85
+ else
86
+ raise Error, "Pool must respond to #checkout or #acquire"
87
+ end
88
+ end
89
+
90
+ # Release a connection back to the pool.
91
+ # @param connection [Object] the connection to release
92
+ def release(connection)
93
+ if @pool.respond_to?(:checkin)
94
+ @pool.checkin(connection)
95
+ elsif connection.respond_to?(:close)
96
+ connection.close
97
+ end
98
+ end
99
+ end
100
+
101
+ # Abstract interface for connection pools.
102
+ # Implementations manage a pool of reusable database connections.
103
+ class ConnectionPool
104
+ # Check out a connection from the pool.
105
+ # @return [Object] a database connection
106
+ def checkout
107
+ raise NotImplementedError, "#{self.class} must implement #checkout"
108
+ end
109
+
110
+ # Check in a connection back to the pool.
111
+ # @param connection [Object] the connection to return
112
+ def checkin(connection)
113
+ raise NotImplementedError, "#{self.class} must implement #checkin"
114
+ end
115
+
116
+ # @return [Integer] total pool size
117
+ def size
118
+ raise NotImplementedError, "#{self.class} must implement #size"
119
+ end
120
+
121
+ # @return [Integer] number of available connections
122
+ def available
123
+ raise NotImplementedError, "#{self.class} must implement #available"
124
+ end
125
+
126
+ # Shutdown the pool and close all connections.
127
+ def shutdown
128
+ raise NotImplementedError, "#{self.class} must implement #shutdown"
129
+ end
130
+ end
131
+ end