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,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dorm
4
+ class QueryBuilder
5
+ attr_reader :table_name, :data_class
6
+
7
+ def initialize(table_name, data_class)
8
+ @table_name = table_name
9
+ @data_class = data_class
10
+ @select_fields = ["#{table_name}.*"]
11
+ @joins = []
12
+ @where_conditions = []
13
+ @group_by_fields = []
14
+ @having_conditions = []
15
+ @order_by_fields = []
16
+ @limit_value = nil
17
+ @offset_value = nil
18
+ @params = []
19
+ @param_counter = 0
20
+ end
21
+
22
+ # SELECT methods
23
+ def select(*fields)
24
+ clone.tap do |query|
25
+ if fields.empty?
26
+ query.instance_variable_set(:@select_fields, ["#{table_name}.*"])
27
+ else
28
+ formatted_fields = fields.map do |field|
29
+ case field
30
+ when Symbol
31
+ "#{table_name}.#{field}"
32
+ when String
33
+ field.include?(".") ? field : "#{table_name}.#{field}"
34
+ else
35
+ field.to_s
36
+ end
37
+ end
38
+ query.instance_variable_set(:@select_fields, formatted_fields)
39
+ end
40
+ end
41
+ end
42
+
43
+ def select_raw(sql)
44
+ clone.tap do |query|
45
+ query.instance_variable_set(:@select_fields, [sql])
46
+ end
47
+ end
48
+
49
+ # WHERE methods
50
+ def where(conditions = nil, **kwargs, &block)
51
+ clone.tap do |query|
52
+ if block_given?
53
+ # DSL block: where { name.eq("Alice").and(age.gt(18)) }
54
+ dsl = WhereDSL.new(table_name, query.instance_variable_get(:@param_counter))
55
+ condition = dsl.instance_eval(&block)
56
+ query.instance_variable_get(:@where_conditions) << [condition.to_sql, condition.params]
57
+ query.instance_variable_set(:@param_counter,
58
+ query.instance_variable_get(:@param_counter) + condition.params.length)
59
+ elsif conditions.is_a?(Hash) || !kwargs.empty?
60
+ # Hash conditions: where(name: "Alice", age: 25)
61
+ hash_conditions = conditions.is_a?(Hash) ? conditions : kwargs
62
+ query.add_hash_conditions(hash_conditions)
63
+ elsif conditions.is_a?(String)
64
+ # Raw SQL: where("name = ? AND age > ?", "Alice", 18)
65
+ query.add_where_condition(conditions, [])
66
+ end
67
+ end
68
+ end
69
+
70
+ def where_raw(sql, *params)
71
+ clone.tap do |query|
72
+ query.add_where_condition(sql, params)
73
+ end
74
+ end
75
+
76
+ # JOIN methods
77
+ def join(table, condition = nil, **kwargs)
78
+ add_join("INNER JOIN", table, condition, kwargs)
79
+ end
80
+
81
+ def left_join(table, condition = nil, **kwargs)
82
+ add_join("LEFT JOIN", table, condition, kwargs)
83
+ end
84
+
85
+ def right_join(table, condition = nil, **kwargs)
86
+ add_join("RIGHT JOIN", table, condition, kwargs)
87
+ end
88
+
89
+ def inner_join(table, condition = nil, **kwargs)
90
+ add_join("INNER JOIN", table, condition, kwargs)
91
+ end
92
+
93
+ # GROUP BY and HAVING
94
+ def group_by(*fields)
95
+ clone.tap do |query|
96
+ formatted_fields = fields.map { |f| format_field(f) }
97
+ query.instance_variable_set(:@group_by_fields,
98
+ query.instance_variable_get(:@group_by_fields) + formatted_fields)
99
+ end
100
+ end
101
+
102
+ def having(condition, *params)
103
+ clone.tap do |query|
104
+ query.instance_variable_get(:@having_conditions) << [condition, params]
105
+ query.instance_variable_set(:@param_counter,
106
+ query.instance_variable_get(:@param_counter) + params.length)
107
+ end
108
+ end
109
+
110
+ # ORDER BY
111
+ def order_by(*fields)
112
+ clone.tap do |query|
113
+ formatted_fields = fields.map do |field|
114
+ case field
115
+ when Hash
116
+ field.map { |f, direction| "#{format_field(f)} #{direction.to_s.upcase}" }.join(", ")
117
+ else
118
+ format_field(field)
119
+ end
120
+ end
121
+ query.instance_variable_set(:@order_by_fields, formatted_fields)
122
+ end
123
+ end
124
+
125
+ def order(field, direction = :asc)
126
+ order_by(field => direction)
127
+ end
128
+
129
+ # LIMIT and OFFSET
130
+ def limit(count)
131
+ clone.tap do |query|
132
+ query.instance_variable_set(:@limit_value, count)
133
+ end
134
+ end
135
+
136
+ def offset(count)
137
+ clone.tap do |query|
138
+ query.instance_variable_set(:@offset_value, count)
139
+ end
140
+ end
141
+
142
+ # Pagination helpers
143
+ def page(page_num, per_page = 20)
144
+ offset_count = (page_num - 1) * per_page
145
+ limit(per_page).offset(offset_count)
146
+ end
147
+
148
+ # Execution methods
149
+ def to_sql
150
+ build_sql
151
+ end
152
+
153
+ def to_a
154
+ execute.value_or([])
155
+ end
156
+
157
+ def first
158
+ limit(1).execute.bind do |results|
159
+ result = results.first
160
+ result ? Result.success(result) : Result.failure("No records found")
161
+ end
162
+ end
163
+
164
+ def count
165
+ select_raw("COUNT(*) as count")
166
+ .limit(nil)
167
+ .offset(nil)
168
+ .execute
169
+ .map { |results| results.first&.[]("count")&.to_i || 0 }
170
+ end
171
+
172
+ def exists?
173
+ select_raw("1")
174
+ .limit(1)
175
+ .execute
176
+ .map { |results| !results.empty? }
177
+ end
178
+
179
+ def execute
180
+ Result.try do
181
+ sql, params = build_sql_with_params
182
+ result = Database.query(sql, params)
183
+
184
+ case result
185
+ when ->(r) { r.respond_to?(:map) }
186
+ result.map { |row| row_to_data(row) }
187
+ else
188
+ [row_to_data(result)]
189
+ end
190
+ end
191
+ end
192
+
193
+ # Aggregation methods
194
+ def sum(field)
195
+ select_raw("SUM(#{format_field(field)}) as sum")
196
+ .execute
197
+ .map { |results| results.first&.[]("sum")&.to_f || 0 }
198
+ end
199
+
200
+ def avg(field)
201
+ select_raw("AVG(#{format_field(field)}) as avg")
202
+ .execute
203
+ .map { |results| results.first&.[]("avg")&.to_f || 0 }
204
+ end
205
+
206
+ def max(field)
207
+ select_raw("MAX(#{format_field(field)}) as max")
208
+ .execute
209
+ .map { |results| results.first&.[]("max") }
210
+ end
211
+
212
+ def min(field)
213
+ select_raw("MIN(#{format_field(field)}) as min")
214
+ .execute
215
+ .map { |results| results.first&.[]("min") }
216
+ end
217
+
218
+ def add_where_condition(condition, params)
219
+ @where_conditions << [condition, params]
220
+ end
221
+
222
+ def add_hash_conditions(hash)
223
+ hash.each do |field, value|
224
+ if value.is_a?(Array)
225
+ placeholders = value.map { next_placeholder }.join(", ")
226
+ @where_conditions << ["#{format_field(field)} IN (#{placeholders})", value]
227
+ elsif value.is_a?(Range)
228
+ condition = "#{format_field(field)} BETWEEN #{next_placeholder} AND #{next_placeholder}"
229
+ @where_conditions << [condition, [value.begin, value.end]]
230
+ elsif value.nil?
231
+ @where_conditions << ["#{format_field(field)} IS NULL", []]
232
+ else
233
+ condition = "#{format_field(field)} = #{next_placeholder}"
234
+ @where_conditions << [condition, [value]]
235
+ end
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ def clone
242
+ Marshal.load(Marshal.dump(self))
243
+ end
244
+
245
+ def add_join(join_type, table, condition, kwargs)
246
+ clone.tap do |query|
247
+ if condition
248
+ join_clause = "#{join_type} #{table} ON #{condition}"
249
+ elsif kwargs.any?
250
+ # Auto-generate join condition from kwargs
251
+ conditions = kwargs.map do |local_field, foreign_field|
252
+ "#{table_name}.#{local_field} = #{table}.#{foreign_field}"
253
+ end.join(" AND ")
254
+ join_clause = "#{join_type} #{table} ON #{conditions}"
255
+ else
256
+ raise ArgumentError, "Join requires either condition or field mapping"
257
+ end
258
+
259
+ query.instance_variable_get(:@joins) << join_clause
260
+ end
261
+ end
262
+
263
+ def format_field(field)
264
+ case field
265
+ when Symbol
266
+ "#{table_name}.#{field}"
267
+ when String
268
+ field.include?(".") ? field : "#{table_name}.#{field}"
269
+ else
270
+ field.to_s
271
+ end
272
+ end
273
+
274
+ def next_placeholder
275
+ @param_counter += 1
276
+ "$#{@param_counter}"
277
+ end
278
+
279
+ def build_sql_with_params
280
+ sql = build_sql
281
+ params = collect_params
282
+ [sql, params]
283
+ end
284
+
285
+ def build_sql
286
+ parts = []
287
+
288
+ parts << "SELECT #{@select_fields.join(", ")}"
289
+ parts << "FROM #{table_name}"
290
+ parts.concat(@joins) if @joins.any?
291
+
292
+ if @where_conditions.any?
293
+ where_clause = @where_conditions.map { |condition, _| condition }.join(" AND ")
294
+ parts << "WHERE #{where_clause}"
295
+ end
296
+
297
+ parts << "GROUP BY #{@group_by_fields.join(", ")}" if @group_by_fields.any?
298
+
299
+ if @having_conditions.any?
300
+ having_clause = @having_conditions.map { |condition, _| condition }.join(" AND ")
301
+ parts << "HAVING #{having_clause}"
302
+ end
303
+
304
+ parts << "ORDER BY #{@order_by_fields.join(", ")}" if @order_by_fields.any?
305
+ parts << "LIMIT #{@limit_value}" if @limit_value
306
+ parts << "OFFSET #{@offset_value}" if @offset_value
307
+
308
+ parts.join(" ")
309
+ end
310
+
311
+ def collect_params
312
+ params = []
313
+ @where_conditions.each { |_, condition_params| params.concat(condition_params) }
314
+ @having_conditions.each { |_, condition_params| params.concat(condition_params) }
315
+ params
316
+ end
317
+
318
+ def row_to_data(row)
319
+ return row unless @data_class && @select_fields == ["#{table_name}.*"]
320
+
321
+ attrs = {}
322
+ @data_class.members.each do |col|
323
+ attrs[col] = deserialize_value(col, row[col.to_s])
324
+ end
325
+ @data_class.new(**attrs)
326
+ end
327
+
328
+ def deserialize_value(column, value)
329
+ return nil if value.nil?
330
+
331
+ case column
332
+ when :id, /.*_id$/
333
+ value.to_i
334
+ when :created_at, :updated_at
335
+ Time.parse(value.to_s)
336
+ else
337
+ value
338
+ end
339
+ end
340
+ end
341
+
342
+ # DSL for building WHERE conditions
343
+ class WhereDSL
344
+ attr_reader :params
345
+
346
+ def initialize(table_name, param_counter)
347
+ @table_name = table_name
348
+ @param_counter = param_counter
349
+ @params = []
350
+ end
351
+
352
+ def method_missing(field_name, *args)
353
+ FieldCondition.new(field_name, @table_name, self)
354
+ end
355
+
356
+ def add_param(value)
357
+ @params << value
358
+ @param_counter += 1
359
+ "$#{@param_counter}"
360
+ end
361
+
362
+ class FieldCondition
363
+ def initialize(field, table_name, dsl)
364
+ @field = field
365
+ @table_name = table_name
366
+ @dsl = dsl
367
+ end
368
+
369
+ def eq(value)
370
+ Condition.new("#{@table_name}.#{@field} = #{@dsl.add_param(value)}", @dsl.params.dup)
371
+ end
372
+
373
+ def ne(value)
374
+ Condition.new("#{@table_name}.#{@field} != #{@dsl.add_param(value)}", @dsl.params.dup)
375
+ end
376
+
377
+ def gt(value)
378
+ Condition.new("#{@table_name}.#{@field} > #{@dsl.add_param(value)}", @dsl.params.dup)
379
+ end
380
+
381
+ def gte(value)
382
+ Condition.new("#{@table_name}.#{@field} >= #{@dsl.add_param(value)}", @dsl.params.dup)
383
+ end
384
+
385
+ def lt(value)
386
+ Condition.new("#{@table_name}.#{@field} < #{@dsl.add_param(value)}", @dsl.params.dup)
387
+ end
388
+
389
+ def lte(value)
390
+ Condition.new("#{@table_name}.#{@field} <= #{@dsl.add_param(value)}", @dsl.params.dup)
391
+ end
392
+
393
+ def like(pattern)
394
+ Condition.new("#{@table_name}.#{@field} LIKE #{@dsl.add_param(pattern)}", @dsl.params.dup)
395
+ end
396
+
397
+ def in(values)
398
+ placeholders = values.map { |v| @dsl.add_param(v) }.join(", ")
399
+ Condition.new("#{@table_name}.#{@field} IN (#{placeholders})", @dsl.params.dup)
400
+ end
401
+
402
+ def null
403
+ Condition.new("#{@table_name}.#{@field} IS NULL", @dsl.params.dup)
404
+ end
405
+
406
+ def not_null
407
+ Condition.new("#{@table_name}.#{@field} IS NOT NULL", @dsl.params.dup)
408
+ end
409
+ end
410
+
411
+ class Condition
412
+ attr_reader :sql, :params
413
+
414
+ def initialize(sql, params)
415
+ @sql = sql
416
+ @params = params
417
+ end
418
+
419
+ def and(other_condition)
420
+ combined_params = @params + other_condition.params
421
+ Condition.new("(#{@sql}) AND (#{other_condition.sql})", combined_params)
422
+ end
423
+
424
+ def or(other_condition)
425
+ combined_params = @params + other_condition.params
426
+ Condition.new("(#{@sql}) OR (#{other_condition.sql})", combined_params)
427
+ end
428
+
429
+ def to_sql
430
+ @sql
431
+ end
432
+ end
433
+ end
434
+ end