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.
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "date"
6
+
7
+ module Rooq
8
+ # Result wraps a database result set and provides:
9
+ # - Symbol keys instead of string keys
10
+ # - Automatic type coercion for JSON, JSONB, ARRAY, timestamps, dates
11
+ # - Enumerable interface
12
+ #
13
+ # @example
14
+ # result = ctx.fetch_all(query)
15
+ # result.each do |row|
16
+ # puts row[:title] # Symbol key access
17
+ # puts row[:tags].first # Array is parsed
18
+ # puts row[:metadata] # JSON is parsed to Hash
19
+ # end
20
+ class Result
21
+ include Enumerable
22
+
23
+ attr_reader :raw_result
24
+
25
+ # @param raw_result [PG::Result] the raw database result
26
+ # @param coercer [TypeCoercer] optional custom type coercer
27
+ def initialize(raw_result, coercer: TypeCoercer.new)
28
+ @raw_result = raw_result
29
+ @coercer = coercer
30
+ @field_info = build_field_info
31
+ @rows = nil
32
+ end
33
+
34
+ # @yield [Hash] each row with symbol keys
35
+ def each(&block)
36
+ rows.each(&block)
37
+ end
38
+
39
+ # @return [Hash, nil] the first row or nil
40
+ def first
41
+ rows.first
42
+ end
43
+
44
+ # @return [Array<Hash>] all rows
45
+ def to_a
46
+ rows.dup
47
+ end
48
+
49
+ # @return [Boolean] true if no rows
50
+ def empty?
51
+ size.zero?
52
+ end
53
+
54
+ # @return [Integer] number of rows
55
+ def size
56
+ @raw_result.ntuples
57
+ end
58
+ alias length size
59
+ alias count size
60
+
61
+ private
62
+
63
+ def rows
64
+ @rows ||= build_rows
65
+ end
66
+
67
+ def build_rows
68
+ result = []
69
+ @raw_result.ntuples.times do |row_index|
70
+ result << build_row(row_index)
71
+ end
72
+ result
73
+ end
74
+
75
+ def build_row(row_index)
76
+ row = {}
77
+ @field_info.each_with_index do |(name, oid), col_index|
78
+ value = @raw_result.getvalue(row_index, col_index)
79
+ row[name] = @coercer.coerce(value, oid)
80
+ end
81
+ row
82
+ end
83
+
84
+ def build_field_info
85
+ info = []
86
+ @raw_result.nfields.times do |i|
87
+ name = @raw_result.fname(i).to_sym
88
+ oid = @raw_result.ftype(i)
89
+ info << [name, oid]
90
+ end
91
+ info
92
+ end
93
+ end
94
+
95
+ # TypeCoercer converts PostgreSQL values to Ruby types based on OID.
96
+ class TypeCoercer
97
+ # PostgreSQL type OIDs
98
+ OID_BOOL = 16
99
+ OID_INT2 = 21
100
+ OID_INT4 = 23
101
+ OID_INT8 = 20
102
+ OID_FLOAT4 = 700
103
+ OID_FLOAT8 = 701
104
+ OID_NUMERIC = 1700
105
+ OID_TEXT = 25
106
+ OID_VARCHAR = 1043
107
+ OID_DATE = 1082
108
+ OID_TIMESTAMP = 1114
109
+ OID_TIMESTAMPTZ = 1184
110
+ OID_JSON = 114
111
+ OID_JSONB = 3802
112
+ OID_INT4_ARRAY = 1007
113
+ OID_INT8_ARRAY = 1016
114
+ OID_TEXT_ARRAY = 1009
115
+ OID_VARCHAR_ARRAY = 1015
116
+ OID_FLOAT4_ARRAY = 1021
117
+ OID_FLOAT8_ARRAY = 1022
118
+ OID_BOOL_ARRAY = 1000
119
+ OID_UUID = 2950
120
+
121
+ # Coerce a value based on its PostgreSQL OID.
122
+ # @param value [String, nil] the raw value from the database
123
+ # @param oid [Integer] the PostgreSQL type OID
124
+ # @return [Object] the coerced value
125
+ def coerce(value, oid)
126
+ return nil if value.nil?
127
+
128
+ case oid
129
+ when OID_JSON, OID_JSONB
130
+ coerce_json(value)
131
+ when OID_INT4_ARRAY, OID_INT8_ARRAY
132
+ coerce_int_array(value)
133
+ when OID_TEXT_ARRAY, OID_VARCHAR_ARRAY
134
+ coerce_text_array(value)
135
+ when OID_FLOAT4_ARRAY, OID_FLOAT8_ARRAY
136
+ coerce_float_array(value)
137
+ when OID_BOOL_ARRAY
138
+ coerce_bool_array(value)
139
+ when OID_TIMESTAMP, OID_TIMESTAMPTZ
140
+ coerce_timestamp(value)
141
+ when OID_DATE
142
+ coerce_date(value)
143
+ when OID_BOOL
144
+ coerce_bool(value)
145
+ when OID_INT2, OID_INT4, OID_INT8
146
+ coerce_integer(value)
147
+ when OID_FLOAT4, OID_FLOAT8, OID_NUMERIC
148
+ coerce_float(value)
149
+ else
150
+ value
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ def coerce_json(value)
157
+ JSON.parse(value)
158
+ rescue JSON::ParserError
159
+ value
160
+ end
161
+
162
+ def coerce_int_array(value)
163
+ parse_pg_array(value).map { |v| v&.to_i }
164
+ end
165
+
166
+ def coerce_text_array(value)
167
+ parse_pg_array(value)
168
+ end
169
+
170
+ def coerce_float_array(value)
171
+ parse_pg_array(value).map { |v| v&.to_f }
172
+ end
173
+
174
+ def coerce_bool_array(value)
175
+ parse_pg_array(value).map { |v| coerce_bool(v) }
176
+ end
177
+
178
+ def coerce_timestamp(value)
179
+ Time.parse(value)
180
+ rescue ArgumentError
181
+ value
182
+ end
183
+
184
+ def coerce_date(value)
185
+ Date.parse(value)
186
+ rescue ArgumentError
187
+ value
188
+ end
189
+
190
+ def coerce_bool(value)
191
+ return nil if value.nil?
192
+ return value if value == true || value == false
193
+
194
+ value == "t" || value == "true" || value == "1"
195
+ end
196
+
197
+ def coerce_integer(value)
198
+ return value if value.is_a?(Integer)
199
+
200
+ value.to_i
201
+ end
202
+
203
+ def coerce_float(value)
204
+ return value if value.is_a?(Float)
205
+
206
+ value.to_f
207
+ end
208
+
209
+ # Parse PostgreSQL array literal format: {val1,val2,val3}
210
+ def parse_pg_array(value)
211
+ return [] if value == "{}"
212
+
213
+ # Remove outer braces
214
+ inner = value[1..-2]
215
+ return [] if inner.nil? || inner.empty?
216
+
217
+ elements = []
218
+ current = ""
219
+ in_quotes = false
220
+ escape_next = false
221
+
222
+ inner.each_char do |char|
223
+ if escape_next
224
+ current += char
225
+ escape_next = false
226
+ elsif char == '\\'
227
+ escape_next = true
228
+ elsif char == '"'
229
+ in_quotes = !in_quotes
230
+ elsif char == ',' && !in_quotes
231
+ elements << parse_array_element(current)
232
+ current = ""
233
+ else
234
+ current += char
235
+ end
236
+ end
237
+
238
+ elements << parse_array_element(current) unless current.empty?
239
+ elements
240
+ end
241
+
242
+ def parse_array_element(str)
243
+ return nil if str == "NULL"
244
+
245
+ str
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rooq
4
+ class SchemaValidator
5
+ def initialize(connection, schema: "public")
6
+ @introspector = Generator::Introspector.new(connection)
7
+ @schema = schema
8
+ end
9
+
10
+ def validate(tables)
11
+ errors = []
12
+
13
+ tables.each do |table|
14
+ table_errors = validate_table(table)
15
+ errors.concat(table_errors)
16
+ end
17
+
18
+ raise SchemaValidationError.new(errors) unless errors.empty?
19
+
20
+ true
21
+ end
22
+
23
+ private
24
+
25
+ def validate_table(table)
26
+ errors = []
27
+
28
+ db_tables = @introspector.introspect_tables(schema: @schema)
29
+
30
+ unless db_tables.include?(table.name.to_s)
31
+ errors << "Table '#{table.name}' does not exist in database"
32
+ return errors
33
+ end
34
+
35
+ db_columns = @introspector.introspect_columns(table.name.to_s, schema: @schema)
36
+ db_column_names = db_columns.map(&:name)
37
+
38
+ table.fields.each_key do |field_name|
39
+ unless db_column_names.include?(field_name.to_s)
40
+ errors << "Column '#{field_name}' on table '#{table.name}' does not exist in database"
41
+ end
42
+ end
43
+
44
+ errors
45
+ end
46
+ end
47
+
48
+ class SchemaValidationError < Error
49
+ attr_reader :validation_errors
50
+
51
+ def initialize(errors)
52
+ @validation_errors = errors
53
+ super("Schema validation failed:\n - #{errors.join("\n - ")}")
54
+ end
55
+ end
56
+ end
data/lib/rooq/table.rb ADDED
@@ -0,0 +1,69 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Rooq
7
+ class Table
8
+ extend T::Sig
9
+
10
+ sig { returns(Symbol) }
11
+ attr_reader :name
12
+
13
+ sig { returns(T::Hash[Symbol, Field]) }
14
+ attr_reader :fields
15
+
16
+ sig { params(name: Symbol, block: T.nilable(T.proc.params(builder: TableBuilder).void)).void }
17
+ def initialize(name, &block)
18
+ @name = name
19
+ @fields = T.let({}, T::Hash[Symbol, Field])
20
+ @field_accessors = T.let({}, T::Hash[Symbol, Field])
21
+
22
+ if block_given?
23
+ builder = TableBuilder.new(self)
24
+ block.call(builder)
25
+ end
26
+
27
+ @fields.freeze
28
+ freeze
29
+ end
30
+
31
+ sig { returns(T::Array[Field]) }
32
+ def asterisk
33
+ @fields.values
34
+ end
35
+
36
+ sig { params(method_name: Symbol, args: T.untyped).returns(Field) }
37
+ def method_missing(method_name, *args)
38
+ field_name = method_name.to_s.downcase.to_sym
39
+
40
+ if @fields.key?(field_name)
41
+ T.must(@fields[field_name])
42
+ else
43
+ available = @fields.keys.join(", ")
44
+ raise ValidationError, "Unknown field '#{field_name}' on table '#{@name}'. Available fields: #{available}"
45
+ end
46
+ end
47
+
48
+ sig { params(method_name: Symbol, include_private: T::Boolean).returns(T::Boolean) }
49
+ def respond_to_missing?(method_name, include_private = false)
50
+ field_name = method_name.to_s.downcase.to_sym
51
+ @fields.key?(field_name) || super
52
+ end
53
+
54
+ class TableBuilder
55
+ extend T::Sig
56
+
57
+ sig { params(table: Table).void }
58
+ def initialize(table)
59
+ @table = table
60
+ end
61
+
62
+ sig { params(name: Symbol, type: Symbol).returns(Field) }
63
+ def field(name, type)
64
+ field = Field.new(name, @table.name, type)
65
+ @table.instance_variable_get(:@fields)[name] = field
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rooq
4
+ VERSION = "1.0.0"
5
+ end
data/lib/rooq.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rooq
4
+ class Error < StandardError; end
5
+ class SchemaError < Error; end
6
+ class ValidationError < Error; end
7
+ end
8
+
9
+ require_relative "rooq/version"
10
+ require_relative "rooq/connection"
11
+ require_relative "rooq/configuration"
12
+ require_relative "rooq/expression"
13
+ require_relative "rooq/field"
14
+ require_relative "rooq/table"
15
+ require_relative "rooq/condition"
16
+ require_relative "rooq/dsl"
17
+ require_relative "rooq/dialect"
18
+ require_relative "rooq/generator"
19
+ require_relative "rooq/result"
20
+ require_relative "rooq/parameter_converter"
21
+ require_relative "rooq/executor"
22
+ require_relative "rooq/context"
23
+ require_relative "rooq/schema_validator"
24
+ require_relative "rooq/query_validator"
25
+ require_relative "rooq/adapters"
data/rooq.gemspec ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rooq/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rooq"
7
+ spec.version = Rooq::VERSION
8
+ spec.authors = ["Guillermo G. Almazor"]
9
+ spec.email = ["guille@galmazor.com"]
10
+
11
+ spec.summary = "A jOOQ-inspired query builder for Ruby"
12
+ spec.description = "Build type-safe SQL queries using a fluent, chainable API. Generate Ruby code from database schemas with optional Sorbet type annotations."
13
+ spec.homepage = "https://github.com/ggalmazor/rooq"
14
+ spec.license = "AGPL-3.0-only"
15
+ spec.required_ruby_version = ">= 3.4"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (File.expand_path(f) == __FILE__) ||
25
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github .idea docs/])
26
+ end
27
+ end
28
+
29
+ spec.bindir = "exe"
30
+ spec.executables = ["rooq"]
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency "pg", "~> 1.5"
34
+ spec.add_dependency "sorbet-runtime", "~> 0.5"
35
+ end
data/sorbet/config ADDED
@@ -0,0 +1,4 @@
1
+ --dir
2
+ .
3
+ --ignore=/tmp/
4
+ --ignore=/vendor/bundle
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rooq
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Guillermo G. Almazor
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: pg
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sorbet-runtime
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.5'
40
+ description: Build type-safe SQL queries using a fluent, chainable API. Generate Ruby
41
+ code from database schemas with optional Sorbet type annotations.
42
+ email:
43
+ - guille@galmazor.com
44
+ executables:
45
+ - rooq
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".tool-versions"
50
+ - ".yardopts"
51
+ - CHANGELOG.md
52
+ - CLAUDE.md
53
+ - Gemfile
54
+ - Gemfile.lock
55
+ - LICENSE
56
+ - README.md
57
+ - Rakefile
58
+ - USAGE.md
59
+ - exe/rooq
60
+ - lib/rooq.rb
61
+ - lib/rooq/adapters.rb
62
+ - lib/rooq/adapters/postgresql.rb
63
+ - lib/rooq/cli.rb
64
+ - lib/rooq/condition.rb
65
+ - lib/rooq/configuration.rb
66
+ - lib/rooq/connection.rb
67
+ - lib/rooq/context.rb
68
+ - lib/rooq/dialect.rb
69
+ - lib/rooq/dialect/base.rb
70
+ - lib/rooq/dialect/postgresql.rb
71
+ - lib/rooq/dsl.rb
72
+ - lib/rooq/dsl/delete_query.rb
73
+ - lib/rooq/dsl/insert_query.rb
74
+ - lib/rooq/dsl/select_query.rb
75
+ - lib/rooq/dsl/update_query.rb
76
+ - lib/rooq/executor.rb
77
+ - lib/rooq/expression.rb
78
+ - lib/rooq/field.rb
79
+ - lib/rooq/generator.rb
80
+ - lib/rooq/generator/code_generator.rb
81
+ - lib/rooq/generator/introspector.rb
82
+ - lib/rooq/parameter_converter.rb
83
+ - lib/rooq/query_validator.rb
84
+ - lib/rooq/result.rb
85
+ - lib/rooq/schema_validator.rb
86
+ - lib/rooq/table.rb
87
+ - lib/rooq/version.rb
88
+ - rooq.gemspec
89
+ - sorbet/config
90
+ homepage: https://github.com/ggalmazor/rooq
91
+ licenses:
92
+ - AGPL-3.0-only
93
+ metadata:
94
+ homepage_uri: https://github.com/ggalmazor/rooq
95
+ source_code_uri: https://github.com/ggalmazor/rooq
96
+ changelog_uri: https://github.com/ggalmazor/rooq/blob/main/CHANGELOG.md
97
+ rubygems_mfa_required: 'true'
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '3.4'
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 4.0.3
113
+ specification_version: 4
114
+ summary: A jOOQ-inspired query builder for Ruby
115
+ test_files: []