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,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dorm
4
+ # Metaprogramming module to generate repository methods
5
+ module Repository
6
+ module_function
7
+
8
+ def for(data_class, table_name: nil, validations: {})
9
+ table_name ||= pluralize(data_class.name.downcase)
10
+
11
+ Module.new do
12
+ extend self
13
+
14
+ # Store metadata about this repository
15
+ define_singleton_method(:data_class) { data_class }
16
+ define_singleton_method(:table_name) { table_name }
17
+ define_singleton_method(:validations) { validations }
18
+ define_singleton_method(:columns) { data_class.members }
19
+ define_singleton_method(:db_columns) { columns - [:id] }
20
+
21
+ # Helper method to get the correct placeholder syntax for the current adapter
22
+ define_singleton_method(:placeholder) do |index|
23
+ case Database.adapter
24
+ when :postgresql
25
+ "$#{index}"
26
+ when :sqlite3
27
+ '?'
28
+ else
29
+ '?'
30
+ end
31
+ end
32
+
33
+ # Helper method to generate placeholders for multiple values
34
+ define_singleton_method(:placeholders) do |count, start_index: 1|
35
+ case Database.adapter
36
+ when :postgresql
37
+ (start_index...(start_index + count)).map { |i| "$#{i}" }
38
+ when :sqlite3
39
+ Array.new(count, '?')
40
+ else
41
+ Array.new(count, '?')
42
+ end
43
+ end
44
+
45
+ # Helper method to handle RETURNING clause differences
46
+ define_singleton_method(:returning_clause) do |column = 'id'|
47
+ case Database.adapter
48
+ when :postgresql
49
+ "RETURNING #{column}"
50
+ when :sqlite3
51
+ "RETURNING #{column}"
52
+ else
53
+ "RETURNING #{column}"
54
+ end
55
+ end
56
+
57
+ # Helper method to check if result is empty (adapter-specific)
58
+ define_singleton_method(:result_empty?) do |result|
59
+ case Database.adapter
60
+ when :postgresql
61
+ result.ntuples == 0
62
+ when :sqlite3
63
+ result.empty?
64
+ else
65
+ result.empty?
66
+ end
67
+ end
68
+
69
+ # Generate standard CRUD methods
70
+ # Find by ID
71
+ define_singleton_method(:find) do |id|
72
+ Result.try do
73
+ result = Database.query("SELECT * FROM #{table_name} WHERE id = #{placeholder(1)}", [id])
74
+ raise 'Record not found' if result_empty?(result)
75
+
76
+ row_to_data(result[0])
77
+ end
78
+ end
79
+
80
+ # Find all records
81
+ define_singleton_method(:find_all) do
82
+ Result.try do
83
+ result = Database.query("SELECT * FROM #{table_name} ORDER BY id")
84
+ result.map { |row| row_to_data(row) }
85
+ end
86
+ end
87
+
88
+ # Create new record
89
+ define_singleton_method(:create) do |attrs|
90
+ Result.try do
91
+ validate_attrs(attrs)
92
+
93
+ now = Time.now
94
+ attrs_with_timestamps = attrs.merge(created_at: now, updated_at: now)
95
+
96
+ # Ensure we don't have an id in the attributes for creation
97
+ attrs_with_timestamps.delete(:id) if attrs_with_timestamps.key?(:id)
98
+
99
+ record = data_class.new(id: nil, **attrs_with_timestamps)
100
+
101
+ columns_list = db_columns.join(', ')
102
+ placeholder_list = placeholders(db_columns.length).join(', ')
103
+ values = db_columns.map { |col| serialize_value(record.send(col)) }
104
+
105
+ result = Database.query(
106
+ "INSERT INTO #{table_name} (#{columns_list}) VALUES (#{placeholder_list}) #{returning_clause}",
107
+ values
108
+ )
109
+
110
+ # Handle different return formats
111
+ id_value = case Database.adapter
112
+ when :postgresql
113
+ result[0]['id'].to_i
114
+ when :sqlite3
115
+ result[0]['id'].to_i
116
+ else
117
+ result[0]['id'].to_i
118
+ end
119
+
120
+ record.with(id: id_value)
121
+ end
122
+ end
123
+
124
+ # Update existing record
125
+ define_singleton_method(:update) do |record|
126
+ Result.try do
127
+ raise 'Cannot update record without id' unless record.id
128
+
129
+ updated_record = record.with(updated_at: Time.now)
130
+
131
+ set_clauses = db_columns.map.with_index(1) { |col, i| "#{col} = #{placeholder(i)}" }.join(', ')
132
+ values = db_columns.map { |col| serialize_value(updated_record.send(col)) }
133
+ values << updated_record.id
134
+
135
+ id_placeholder = placeholder(db_columns.length + 1)
136
+ result = Database.query(
137
+ "UPDATE #{table_name} SET #{set_clauses} WHERE id = #{id_placeholder} #{returning_clause}",
138
+ values
139
+ )
140
+
141
+ raise 'Record not found' if result_empty?(result)
142
+
143
+ updated_record
144
+ end
145
+ end
146
+
147
+ # Save (create or update)
148
+ define_singleton_method(:save) do |record|
149
+ if record.id
150
+ update(record)
151
+ else
152
+ attrs = record.to_h
153
+ attrs.delete(:id) # Remove id key if present
154
+ create(attrs)
155
+ end
156
+ end
157
+
158
+ # Delete record
159
+ define_singleton_method(:delete) do |record|
160
+ Result.try do
161
+ raise 'Cannot delete record without id' unless record.id
162
+
163
+ result = Database.query(
164
+ "DELETE FROM #{table_name} WHERE id = #{placeholder(1)} #{returning_clause}",
165
+ [record.id]
166
+ )
167
+ raise 'Record not found' if result_empty?(result)
168
+
169
+ record
170
+ end
171
+ end
172
+
173
+ # Query methods
174
+ # Where with predicate
175
+ define_singleton_method(:where) do |predicate|
176
+ find_all.map { |records| records.select(&predicate) }
177
+ end
178
+
179
+ # Find by attributes
180
+ define_singleton_method(:find_by) do |**attrs|
181
+ Result.try do
182
+ conditions = attrs.keys.map.with_index(1) { |key, i| "#{key} = #{placeholder(i)}" }.join(' AND ')
183
+ values = attrs.values.map { |val| serialize_value(val) }
184
+
185
+ result = Database.query("SELECT * FROM #{table_name} WHERE #{conditions}", values)
186
+ raise 'Record not found' if result_empty?(result)
187
+
188
+ row_to_data(result[0])
189
+ end
190
+ end
191
+
192
+ # Find all by attributes
193
+ define_singleton_method(:find_all_by) do |**attrs|
194
+ Result.try do
195
+ conditions = attrs.keys.map.with_index(1) { |key, i| "#{key} = #{placeholder(i)}" }.join(' AND ')
196
+ values = attrs.values.map { |val| serialize_value(val) }
197
+
198
+ result = Database.query("SELECT * FROM #{table_name} WHERE #{conditions}", values)
199
+ result.map { |row| row_to_data(row) }
200
+ end
201
+ end
202
+
203
+ # Count records
204
+ define_singleton_method(:count) do
205
+ Result.try do
206
+ result = Database.query("SELECT COUNT(*) as count FROM #{table_name}")
207
+ case Database.adapter
208
+ when :postgresql
209
+ result[0]['count'].to_i
210
+ when :sqlite3
211
+ result[0]['count'].to_i
212
+ else
213
+ result[0]['count'].to_i
214
+ end
215
+ end
216
+ end
217
+
218
+ # Validation method
219
+ define_singleton_method(:validate_attrs) do |attrs|
220
+ validations.each do |field, rules|
221
+ value = attrs[field]
222
+
223
+ if rules[:required] && (value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.respond_to?(:strip) && value.strip.empty?))
224
+ raise ValidationError, "#{field} is required"
225
+ end
226
+
227
+ if rules[:format] && value && !value.match?(rules[:format])
228
+ raise ValidationError, "#{field} has invalid format"
229
+ end
230
+
231
+ if rules[:length] && value && !rules[:length].include?(value.length)
232
+ raise ValidationError, "#{field} length must be #{rules[:length]}"
233
+ end
234
+
235
+ if rules[:range] && value && !rules[:range].include?(value)
236
+ raise ValidationError, "#{field} must be in range #{rules[:range]}"
237
+ end
238
+ end
239
+ end
240
+
241
+ # Helper methods
242
+ define_singleton_method(:row_to_data) do |row|
243
+ attrs = {}
244
+ columns.each do |col|
245
+ attrs[col] = deserialize_value(col, row[col.to_s])
246
+ end
247
+ data_class.new(**attrs)
248
+ end
249
+
250
+ define_singleton_method(:serialize_value) do |value|
251
+ case value
252
+ when Time
253
+ case Database.adapter
254
+ when :postgresql
255
+ value # PostgreSQL handles Time objects natively
256
+ when :sqlite3
257
+ value.to_s # SQLite needs string representation
258
+ else
259
+ value.to_s
260
+ end
261
+ when true, false
262
+ case Database.adapter
263
+ when :postgresql
264
+ value # PostgreSQL handles booleans natively
265
+ when :sqlite3
266
+ value ? 1 : 0 # SQLite uses integers for booleans
267
+ else
268
+ value
269
+ end
270
+ else
271
+ value
272
+ end
273
+ end
274
+
275
+ define_singleton_method(:deserialize_value) do |column, value|
276
+ return nil if value.nil?
277
+
278
+ case column
279
+ when :id, :user_id, :post_id, :comment_id
280
+ value.to_i
281
+ when :created_at, :updated_at
282
+ case Database.adapter
283
+ when :postgresql
284
+ # PostgreSQL might return Time objects or strings
285
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
286
+ when :sqlite3
287
+ Time.parse(value.to_s)
288
+ else
289
+ Time.parse(value.to_s)
290
+ end
291
+ when /.*_id$/
292
+ value.to_i
293
+ when :published, :active, :approved
294
+ # Handle boolean fields
295
+ case Database.adapter
296
+ when :postgresql
297
+ # PostgreSQL returns actual booleans or 't'/'f' strings
298
+ case value
299
+ when true, 't', 'true', '1', 1
300
+ true
301
+ when false, 'f', 'false', '0', 0
302
+ false
303
+ else
304
+ !!value
305
+ end
306
+ when :sqlite3
307
+ # SQLite returns integers for booleans
308
+ case value
309
+ when 1, '1', 'true', true
310
+ true
311
+ when 0, '0', 'false', false
312
+ false
313
+ else
314
+ !!value
315
+ end
316
+ else
317
+ !!value
318
+ end
319
+ else
320
+ value
321
+ end
322
+ end
323
+ end
324
+ end
325
+
326
+ def pluralize(word)
327
+ # Simple pluralization - could be enhanced with inflector gem
328
+ case word
329
+ when /y$/
330
+ word.sub(/y$/, 'ies')
331
+ when /s$/, /x$/, /z$/, /ch$/, /sh$/
332
+ word + 'es'
333
+ else
334
+ word + 's'
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dorm
4
+ # Result monad using Data - inspired by dry-monads
5
+ module Result
6
+ Success = Data.define(:value) do
7
+ def success? = true
8
+ def failure? = false
9
+
10
+ def bind(&block)
11
+ block.call(value)
12
+ rescue StandardError => e
13
+ Failure.new(error: e.message)
14
+ end
15
+
16
+ def map(&block)
17
+ Success.new(value: block.call(value))
18
+ rescue StandardError => e
19
+ Failure.new(error: e.message)
20
+ end
21
+
22
+ def value_or(default = nil)
23
+ value
24
+ end
25
+ end
26
+
27
+ Failure = Data.define(:error) do
28
+ def success? = false
29
+ def failure? = true
30
+
31
+ def bind(&block)
32
+ self
33
+ end
34
+
35
+ def map(&block)
36
+ self
37
+ end
38
+
39
+ def value_or(default = nil)
40
+ default
41
+ end
42
+ end
43
+
44
+ # Add aliases to instance methods by reopening the Data classes
45
+ Success.class_eval do
46
+ alias_method :fmap, :map
47
+ end
48
+
49
+ Failure.class_eval do
50
+ alias_method :fmap, :map
51
+ end
52
+
53
+ module_function
54
+
55
+ def success(value)
56
+ Success.new(value: value)
57
+ end
58
+
59
+ def failure(error)
60
+ Failure.new(error: error)
61
+ end
62
+
63
+ def try(&block)
64
+ success(block.call)
65
+ rescue StandardError => e
66
+ failure(e.message)
67
+ end
68
+
69
+ # Combine multiple Results
70
+ def combine(*results)
71
+ failures = results.select(&:failure?)
72
+ return failure(failures.map(&:error).join(', ')) unless failures.empty?
73
+
74
+ success(results.map(&:value))
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dorm
4
+ VERSION = "0.1.0"
5
+ end
data/lib/dorm.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dorm/version"
4
+ require_relative "dorm/result"
5
+ require_relative "dorm/database"
6
+ require_relative "dorm/repository"
7
+ require_relative "dorm/query_builder"
8
+ require_relative "dorm/connection_pool"
9
+ require_relative "dorm/functional_helpers"
10
+
11
+ module Dorm
12
+ class Error < StandardError; end
13
+ class ConfigurationError < Error; end
14
+ class ValidationError < Error; end
15
+ class RecordNotFoundError < Error; end
16
+
17
+ def self.configure(**options)
18
+ Database.configure(**options)
19
+ end
20
+
21
+ # Convenience method for creating repositories
22
+ def self.repository_for(data_class, **options)
23
+ Repository.for(data_class, **options)
24
+ end
25
+ end
data/sig/Dorm.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Dorm
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,159 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dorm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ecnal
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.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.4'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bundler
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: yard
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.9'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.9'
110
+ description: |
111
+ Dorm (Data ORM) is a lightweight, functional ORM built on Ruby's Data class.
112
+ Features immutable records, monadic error handling inspired by dry-monads,
113
+ and a functional programming approach to database operations.
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".rubocop.yml"
119
+ - ".ruby-gemset"
120
+ - ".ruby-version"
121
+ - README.md
122
+ - Rakefile
123
+ - examples/connection_pool_example.rb
124
+ - examples/query_builder_examples.rb
125
+ - lib/dorm.rb
126
+ - lib/dorm/connection_pool.rb
127
+ - lib/dorm/database.rb
128
+ - lib/dorm/functional_helpers.rb
129
+ - lib/dorm/query_builder.rb
130
+ - lib/dorm/repository.rb
131
+ - lib/dorm/result.rb
132
+ - lib/dorm/version.rb
133
+ - sig/Dorm.rbs
134
+ homepage: https://github.com/ecnal/dorm
135
+ licenses:
136
+ - MIT
137
+ metadata:
138
+ allowed_push_host: https://rubygems.org
139
+ homepage_uri: https://github.com/ecnal/dorm
140
+ source_code_uri: https://github.com/ecnal/dorm
141
+ changelog_uri: https://github.com/ecnal/dorm/blob/master/CHANGELOG.md
142
+ rdoc_options: []
143
+ require_paths:
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: 3.2.0
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubygems_version: 3.6.9
157
+ specification_version: 4
158
+ summary: A functional ORM using Ruby's Data class with monadic error handling
159
+ test_files: []