kweerie 0.1.4 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +34 -1
- data/CHANGELOG.md +6 -0
- data/README.md +111 -85
- data/lib/kweerie/base.rb +119 -14
- data/lib/kweerie/base_object.rb +35 -0
- data/lib/kweerie/result_class_components/accessors.rb +21 -0
- data/lib/kweerie/result_class_components/comparison.rb +25 -0
- data/lib/kweerie/result_class_components/key_transformation.rb +29 -0
- data/lib/kweerie/result_class_components/serialization.rb +27 -0
- data/lib/kweerie/result_class_components/type_casting.rb +20 -0
- data/lib/kweerie/result_class_generator.rb +51 -0
- data/lib/kweerie/sql_path_resolver.rb +67 -0
- data/lib/kweerie/types/boolean.rb +21 -0
- data/lib/kweerie/types/pg_array.rb +23 -0
- data/lib/kweerie/types/pg_jsonb.rb +14 -0
- data/lib/kweerie/version.rb +1 -1
- data/lib/kweerie.rb +6 -1
- metadata +13 -3
- data/lib/kweerie/base_objects.rb +0 -210
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a1ca73e0c3e5890aa9805eed0b491c100be177b958fc9bab9592eaf4f2cdb0d0
|
4
|
+
data.tar.gz: b79729e675821e02c2ff02e3dfcdb8e5237230364790cc9e59e1d4a953ea8da6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 511ea093375cca24abbb8514a98b26cfb662239b836c3da7140aac4ef06ba7a23bf40074e1d9a973ac56a1cd02d1dd5dac486409968bed8f40b39312b6a8ac6d
|
7
|
+
data.tar.gz: abd8e02e09a00f3d4dd683c5e9e179386c053ed0c390d2f09a8c227866e39ff6303400766f2ff173eaf5d379e3bcad84840621a2fdde5b593c76e8643c72741a
|
data/.rubocop.yml
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
AllCops:
|
2
|
-
TargetRubyVersion:
|
2
|
+
TargetRubyVersion: 3.3
|
3
3
|
|
4
4
|
Style/StringLiterals:
|
5
5
|
Enabled: true
|
@@ -11,3 +11,36 @@ Style/StringLiteralsInInterpolation:
|
|
11
11
|
|
12
12
|
Layout/LineLength:
|
13
13
|
Max: 120
|
14
|
+
|
15
|
+
Style/Documentation:
|
16
|
+
Enabled: false
|
17
|
+
|
18
|
+
Metrics/ClassLength:
|
19
|
+
Enabled: false
|
20
|
+
|
21
|
+
Metrics/CyclomaticComplexity:
|
22
|
+
Enabled: false
|
23
|
+
|
24
|
+
Metrics/MethodLength:
|
25
|
+
Enabled: false
|
26
|
+
|
27
|
+
Metrics/AbcSize:
|
28
|
+
Enabled: false
|
29
|
+
|
30
|
+
Style/MultilineBlockChain:
|
31
|
+
Enabled: false
|
32
|
+
|
33
|
+
Metrics/PerceivedComplexity:
|
34
|
+
Enabled: false
|
35
|
+
|
36
|
+
Metrics/AbcSize:
|
37
|
+
Enabled: false
|
38
|
+
|
39
|
+
Gemspec/RequiredRubyVersion:
|
40
|
+
Enabled: false
|
41
|
+
|
42
|
+
Metrics/BlockLength:
|
43
|
+
Enabled: false
|
44
|
+
|
45
|
+
Naming/AccessorMethodName:
|
46
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -47,30 +47,30 @@ Execute your query:
|
|
47
47
|
|
48
48
|
```ruby
|
49
49
|
results = UserSearch.with(
|
50
|
-
name: '
|
50
|
+
name: 'Eclipsoid%',
|
51
51
|
email: '%@example.com'
|
52
52
|
)
|
53
|
-
# => [{"id"=>
|
53
|
+
# => [{"id"=>109981, "name"=>"Eclipsoid Doe", "email"=>"eclipsoiddoe@example.com"}]
|
54
54
|
```
|
55
55
|
|
56
56
|
### Object Mapping
|
57
57
|
|
58
|
-
While `Kweerie::Base` returns plain hashes, you can use `Kweerie::
|
58
|
+
While `Kweerie::Base` returns plain hashes, you can use `Kweerie::BaseObject` to get typed Ruby objects with proper attribute methods:
|
59
59
|
|
60
60
|
```ruby
|
61
|
-
class UserSearch < Kweerie::
|
61
|
+
class UserSearch < Kweerie::BaseObject
|
62
62
|
bind :name, as: '$1'
|
63
63
|
bind :created_at, as: '$2'
|
64
64
|
end
|
65
65
|
|
66
66
|
# Returns array of objects instead of hashes
|
67
67
|
users = UserSearch.with(
|
68
|
-
name: '
|
68
|
+
name: 'Eclipsoid',
|
69
69
|
created_at: '2024-01-01'
|
70
70
|
)
|
71
71
|
|
72
72
|
user = users.first
|
73
|
-
user.name # => "
|
73
|
+
user.name # => "Eclipsoid"
|
74
74
|
user.created_at # => 2024-01-01 00:00:00 +0000 (Time object)
|
75
75
|
```
|
76
76
|
|
@@ -148,118 +148,149 @@ UsersByStatus.all # ✗ Raises ArgumentError
|
|
148
148
|
UsersByStatus.with(status: 'active') # ✓ Correct usage
|
149
149
|
```
|
150
150
|
|
151
|
-
Both methods work with `Kweerie::Base` and `Kweerie::
|
151
|
+
Both methods work with `Kweerie::Base` and `Kweerie::BaseObject`, returning arrays of hashes or objects respectively:
|
152
152
|
|
153
153
|
```ruby
|
154
154
|
# Returns array of hashes
|
155
155
|
class AllUsers < Kweerie::Base
|
156
156
|
end
|
157
157
|
users = AllUsers.all
|
158
|
-
# => [{"id" => 1, "name" => "
|
158
|
+
# => [{"id" => 1, "name" => "Eclipsoid"}, ...]
|
159
159
|
|
160
160
|
# Returns array of objects
|
161
|
-
class AllUsers < Kweerie::
|
161
|
+
class AllUsers < Kweerie::BaseObject
|
162
162
|
end
|
163
163
|
users = AllUsers.all
|
164
|
-
# => [#<AllUsers id=1 name="
|
164
|
+
# => [#<AllUsers id=1 name="Eclipsoid">, ...]
|
165
165
|
```
|
166
166
|
|
167
|
-
###
|
167
|
+
### Object Interface
|
168
168
|
|
169
|
-
|
169
|
+
BaseObject provide several useful methods:
|
170
170
|
|
171
171
|
```ruby
|
172
|
-
#
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
# In your Ruby code
|
184
|
-
user = UserSearch.with(name: 'Claude').first
|
185
|
-
|
186
|
-
user.created_at # => Time object
|
187
|
-
user.age # => Integer
|
188
|
-
user.score # => Float
|
189
|
-
user.active # => true/false
|
190
|
-
user.metadata # => Hash with string keys
|
191
|
-
user.tags # => Array
|
192
|
-
|
193
|
-
# Nested JSONB data is properly accessible
|
194
|
-
user.metadata["role"] # => "admin"
|
195
|
-
user.metadata["preferences"]["theme"] # => "dark"
|
172
|
+
# Hash-like access
|
173
|
+
user[:name] # => "Eclipsoid"
|
174
|
+
user.fetch(:email, 'N/A') # => Returns 'N/A' if email is nil
|
175
|
+
|
176
|
+
# Serialization
|
177
|
+
user.to_h # => Hash with string keys
|
178
|
+
user.to_json # => JSON string
|
179
|
+
|
180
|
+
# Comparison
|
181
|
+
user1 == user2 # Compare all attributes
|
182
|
+
users.sort_by(&:created_at) # Sortable
|
196
183
|
```
|
197
184
|
|
198
|
-
###
|
185
|
+
### SQL File Location
|
199
186
|
|
200
|
-
|
187
|
+
By default, Kweerie looks for SQL files adjacent to their Ruby query classes. You can customize this behavior:
|
201
188
|
|
202
189
|
```ruby
|
203
|
-
|
204
|
-
|
205
|
-
puts "Admin user: #{name}"
|
206
|
-
in { name:, metadata: { role: "user" } }
|
207
|
-
puts "Regular user: #{name}"
|
190
|
+
# Default behavior - looks for user_search.sql next to this file
|
191
|
+
class UserSearch < Kweerie::Base
|
208
192
|
end
|
209
193
|
|
210
|
-
#
|
211
|
-
|
212
|
-
|
213
|
-
|
194
|
+
# Specify absolute path from Rails root
|
195
|
+
class UserSearch < Kweerie::Base
|
196
|
+
sql_file_location root: 'db/queries/complex_user_search.sql'
|
197
|
+
end
|
198
|
+
|
199
|
+
# Specify path relative to the Ruby file
|
200
|
+
class UserSearch < Kweerie::Base
|
201
|
+
sql_file_location relative: '../sql/user_search.sql'
|
202
|
+
end
|
203
|
+
|
204
|
+
# Explicitly use default behavior
|
205
|
+
class UserSearch < Kweerie::Base
|
206
|
+
sql_file_location :default
|
214
207
|
end
|
215
208
|
```
|
216
209
|
|
217
|
-
|
210
|
+
## Type Casting
|
218
211
|
|
219
|
-
|
212
|
+
`cast_select` provides flexible, explicit type casting for your query result fields. Instead of relying on automatic type inference, you can specify exactly how each field should be cast. By default it will return as the string from the database.
|
213
|
+
|
214
|
+
### Basic Usage
|
220
215
|
|
221
216
|
```ruby
|
222
|
-
|
223
|
-
|
224
|
-
|
217
|
+
class UserQuery < Kweerie::BaseObjects
|
218
|
+
cast_select :age, as: ->(val) { val.to_i }
|
219
|
+
cast_select :active, as: Types::Boolean
|
220
|
+
cast_select :metadata, as: Types::PgJsonb
|
221
|
+
end
|
222
|
+
```
|
225
223
|
|
226
|
-
|
227
|
-
user.to_h # => Hash with string keys
|
228
|
-
user.to_json # => JSON string
|
224
|
+
### Casting Options
|
229
225
|
|
230
|
-
|
231
|
-
|
232
|
-
|
226
|
+
#### Built-in Type Classes
|
227
|
+
The gem includes a few built-in type classes for specific scenarios, you'll find a comprehensive amount of them in rails:
|
228
|
+
|
229
|
+
```ruby
|
230
|
+
# Boolean casting (handles various boolean representations)
|
231
|
+
cast_select :active, as: Types::Boolean
|
232
|
+
|
233
|
+
# JSONB casting (handles both objects and arrays)
|
234
|
+
cast_select :metadata, as: Types::PgJsonb
|
233
235
|
|
234
|
-
#
|
235
|
-
|
236
|
-
user.changes # => Hash of changes with [old, new] values
|
237
|
-
user.original_attributes # => Original attributes from DB
|
236
|
+
# Postgres Array casting
|
237
|
+
cast_select :tags, as: Types::PgArray
|
238
238
|
```
|
239
239
|
|
240
|
-
|
240
|
+
#### Lambda/Proc Casting
|
241
|
+
For simple transformations, you can use a lambda or proc:
|
241
242
|
|
242
|
-
|
243
|
+
```ruby
|
244
|
+
class ProductQuery < Kweerie::BaseObjects
|
245
|
+
cast_select :price, as: ->(val) { val.to_f }
|
246
|
+
cast_select :quantity, as: ->(val) { val.to_i }
|
247
|
+
cast_select :sku, as: ->(val) { val.upcase }
|
248
|
+
end
|
249
|
+
```
|
250
|
+
|
251
|
+
#### Method Reference Casting
|
252
|
+
You can reference an instance method for complex casting logic:
|
243
253
|
|
244
254
|
```ruby
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
255
|
+
class OrderQuery < Kweerie::BaseObjects
|
256
|
+
cast_select :total, as: :calculate_total
|
257
|
+
|
258
|
+
def calculate_total(val)
|
259
|
+
return if val.nil?
|
260
|
+
|
261
|
+
Money.new(val)
|
262
|
+
end
|
250
263
|
end
|
264
|
+
```
|
265
|
+
|
266
|
+
### Custom Type Classes
|
267
|
+
|
268
|
+
For reusable, complex casting logic, you can create custom type classes, or use the ones provided by rails:
|
251
269
|
|
252
|
-
|
253
|
-
|
270
|
+
```ruby
|
271
|
+
class Types::Money
|
272
|
+
def cast(value)
|
273
|
+
return nil if value.nil?
|
274
|
+
|
275
|
+
(value.to_f * 100).to_i # Store as cents
|
276
|
+
end
|
277
|
+
end
|
254
278
|
|
255
|
-
|
256
|
-
|
257
|
-
|
279
|
+
class Types::PGIntArray < Types::PgArray
|
280
|
+
def cast(value)
|
281
|
+
super.map(&:to_i) # Convert array elements to integers
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
class InvoiceQuery < Kweerie::BaseObjects
|
286
|
+
cast_select :amount, as: Types::Money
|
287
|
+
cast_select :line_items, as: Types::PGIntArray
|
288
|
+
end
|
258
289
|
```
|
259
290
|
|
260
291
|
### Performance Considerations
|
261
292
|
|
262
|
-
|
293
|
+
BaseObject creates a unique class for each query result set, with the following optimizations:
|
263
294
|
|
264
295
|
- Classes are cached and reused for subsequent queries
|
265
296
|
- Attribute readers are defined upfront
|
@@ -300,9 +331,9 @@ end
|
|
300
331
|
|
301
332
|
## Requirements
|
302
333
|
|
303
|
-
- Ruby
|
334
|
+
- Ruby 3 or higher
|
304
335
|
- PostgreSQL (this gem is PostgreSQL-specific and uses the `pg` gem)
|
305
|
-
- Rails
|
336
|
+
- Rails 7+ (optional, needed for the generator and default ActiveRecord integration)
|
306
337
|
|
307
338
|
## Features
|
308
339
|
|
@@ -312,14 +343,6 @@ end
|
|
312
343
|
- ✅ Configurable connection handling
|
313
344
|
- ✅ Parameter validation
|
314
345
|
|
315
|
-
### Why Kweerie?
|
316
|
-
|
317
|
-
- **SQL Views Overkill**: When a database view is too heavy-handed but you still want to keep SQL separate from Ruby
|
318
|
-
- **Version Control**: Keep your SQL under version control alongside your Ruby code
|
319
|
-
- **Parameter Safety**: Built-in parameter binding prevents SQL injection
|
320
|
-
- **Simple Interface**: Clean, simple API for executing parameterized queries
|
321
|
-
- **Rails Integration**: Works seamlessly with Rails and ActiveRecord
|
322
|
-
|
323
346
|
### Development
|
324
347
|
|
325
348
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
|
@@ -338,6 +361,9 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
338
361
|
|
339
362
|
## FAQ
|
340
363
|
|
364
|
+
**Q: Why does Kweerie exist?**
|
365
|
+
A: PostgreSQL DB views are powerful and (honestly) preferred for what kweerie offers, but they need migrations to change, which isn't always practical when you just need a query. Kweerie provides that flexibility. Plus, SQL is more readable in a single file than when nested in Ruby code. SQL is powerful and doesn’t always need to be hidden behind abstractions.
|
366
|
+
|
341
367
|
**Q: Why PostgreSQL only?**
|
342
368
|
A: Kweerie uses PostgreSQL-specific features for parameter binding and result handling. Supporting other databases would require different parameter binding syntax and result handling.
|
343
369
|
|
data/lib/kweerie/base.rb
CHANGED
@@ -5,37 +5,142 @@ module Kweerie
|
|
5
5
|
class << self
|
6
6
|
def inherited(subclass)
|
7
7
|
subclass.instance_variable_set(:@bindings, {})
|
8
|
+
subclass.instance_variable_set(:@class_location, caller_locations(1, 1)[0].path)
|
8
9
|
super
|
9
10
|
end
|
10
11
|
|
12
|
+
# == Parameter Binding
|
13
|
+
#
|
14
|
+
# Binds a parameter to a SQL placeholder. Parameters are required by default and
|
15
|
+
# must be provided when executing the query.
|
16
|
+
#
|
17
|
+
# === Options
|
18
|
+
#
|
19
|
+
# * <tt>:as</tt> - The SQL placeholder (e.g., '$1', '$2') that this parameter maps to
|
20
|
+
#
|
21
|
+
# === Examples
|
22
|
+
#
|
23
|
+
# class UserSearch < Kweerie::Base
|
24
|
+
# # Single parameter
|
25
|
+
# bind :name, as: '$1'
|
26
|
+
#
|
27
|
+
# # Multiple parameters
|
28
|
+
# bind :email, as: '$2'
|
29
|
+
# bind :status, as: '$3'
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# # Using the query
|
33
|
+
# UserSearch.with(
|
34
|
+
# name: 'Eclipsoid',
|
35
|
+
# email: '%@example.com',
|
36
|
+
# status: 'active'
|
37
|
+
# )
|
38
|
+
#
|
39
|
+
# === Notes
|
40
|
+
#
|
41
|
+
# * Parameters must be provided in the order they appear in the SQL
|
42
|
+
# * All bound parameters are required
|
43
|
+
# * Use PostgreSQL's COALESCE for optional parameters
|
44
|
+
#
|
11
45
|
def bind(param_name, as:)
|
12
46
|
@bindings[param_name] = as
|
13
47
|
end
|
14
48
|
|
15
49
|
attr_reader :bindings
|
16
50
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
51
|
+
# == SQL File Location
|
52
|
+
#
|
53
|
+
# Specifies the location of the SQL file for this query. By default, Kweerie looks for
|
54
|
+
# an SQL file with the same name as the query class in the same directory.
|
55
|
+
#
|
56
|
+
# === Options
|
57
|
+
#
|
58
|
+
# * <tt>:default</tt> - Use default file naming (class_name.sql in same directory)
|
59
|
+
# * <tt>root: 'path'</tt> - Path relative to Rails.root
|
60
|
+
# * <tt>relative: 'path'</tt> - Path relative to the query class file
|
61
|
+
#
|
62
|
+
# === Examples
|
63
|
+
#
|
64
|
+
# class UserSearch < Kweerie::Base
|
65
|
+
# # Default behavior - looks for user_search.sql in same directory
|
66
|
+
# sql_file_location :default
|
67
|
+
#
|
68
|
+
# # Use a specific file from Rails root
|
69
|
+
# sql_file_location root: 'db/queries/complex_user_search.sql'
|
70
|
+
#
|
71
|
+
# # Use a file relative to this class
|
72
|
+
# sql_file_location relative: '../sql/user_search.sql'
|
73
|
+
# end
|
74
|
+
#
|
75
|
+
# === Notes
|
76
|
+
#
|
77
|
+
# * Root paths require Rails to be defined
|
78
|
+
# * Paths should use forward slashes even on Windows
|
79
|
+
# * File extensions should be included in the path
|
80
|
+
# * Relative paths are relative to the query class file location
|
81
|
+
#
|
82
|
+
def sql_file_location(location = :default)
|
83
|
+
@sql_file_location =
|
84
|
+
case location
|
85
|
+
when :default
|
86
|
+
nil
|
87
|
+
when Hash
|
88
|
+
if location.key?(:root)
|
89
|
+
{ root: location[:root].to_s }
|
90
|
+
elsif location.key?(:relative)
|
91
|
+
{ relative: location[:relative].to_s }
|
92
|
+
else
|
93
|
+
raise ArgumentError,
|
94
|
+
"Invalid sql_file_location option. Use :default, root: 'path', or relative: 'path'"
|
95
|
+
end
|
96
|
+
else
|
97
|
+
raise ArgumentError,
|
98
|
+
"Invalid sql_file_location option. Use :default, root: 'path', or relative: 'path'"
|
29
99
|
end
|
100
|
+
end
|
30
101
|
|
31
|
-
|
32
|
-
|
102
|
+
def sql_path
|
103
|
+
@sql_path ||= SQLPathResolver.new(@sql_file_location, name).resolve
|
33
104
|
end
|
34
105
|
|
35
106
|
def sql_content
|
36
107
|
@sql_content ||= File.read(sql_path)
|
37
108
|
end
|
38
109
|
|
110
|
+
# == Execute Query with Parameters
|
111
|
+
#
|
112
|
+
# Executes the SQL query with the provided parameters. All bound parameters must be provided
|
113
|
+
# unless using .all for parameter-free queries.
|
114
|
+
#
|
115
|
+
# === Parameters
|
116
|
+
#
|
117
|
+
# * <tt>params</tt> - Hash of parameter names and values that match the bound parameters
|
118
|
+
#
|
119
|
+
# === Returns
|
120
|
+
#
|
121
|
+
# Array of hashes representing the query results. When using Kweerie::BaseObject,
|
122
|
+
# returns array of typed objects instead.
|
123
|
+
#
|
124
|
+
# === Examples
|
125
|
+
#
|
126
|
+
# # With parameters
|
127
|
+
# UserSearch.with(
|
128
|
+
# name: 'Eclipsoid',
|
129
|
+
# email: '%@example.com'
|
130
|
+
# )
|
131
|
+
# # => [{"id"=>1, "name"=>"Eclipsoid", "email"=>"eclipsoid@example.com"}]
|
132
|
+
#
|
133
|
+
# # With type casting (BaseObject)
|
134
|
+
# UserSearch.with(created_after: '2024-01-01')
|
135
|
+
# # => [#<UserSearch id=1 created_at=2024-01-01 00:00:00 +0000>]
|
136
|
+
#
|
137
|
+
# === Notes
|
138
|
+
#
|
139
|
+
# * Raises ArgumentError if required parameters are missing
|
140
|
+
# * Raises ArgumentError if extra parameters are provided
|
141
|
+
# * Returns empty array if no results found
|
142
|
+
# * Parameters are bound safely using pg-ruby's parameter binding
|
143
|
+
#
|
39
144
|
def with(params = {})
|
40
145
|
validate_params!(params)
|
41
146
|
param_values = order_params(params)
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/string"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module Kweerie
|
7
|
+
class BaseObject < Base
|
8
|
+
class << self
|
9
|
+
def with(params = {})
|
10
|
+
results = super
|
11
|
+
return [] if results.empty?
|
12
|
+
|
13
|
+
# Create a unique result class for this query
|
14
|
+
result_class = generate_result_class(results.first.keys)
|
15
|
+
|
16
|
+
# Map results to objects
|
17
|
+
results.map { |row| result_class.new(row) }
|
18
|
+
end
|
19
|
+
|
20
|
+
def cast_select(field, as: nil)
|
21
|
+
cast_definitions[field] = as if as
|
22
|
+
end
|
23
|
+
|
24
|
+
def cast_definitions
|
25
|
+
@cast_definitions ||= {}
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def generate_result_class(attribute_names)
|
31
|
+
@generate_result_class ||= ResultClassGenerator.generate(self, attribute_names)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ResultClassComponents
|
4
|
+
module Accessors
|
5
|
+
def [](key)
|
6
|
+
instance_variable_get("@#{key}")
|
7
|
+
end
|
8
|
+
|
9
|
+
def fetch(key, default = nil)
|
10
|
+
instance_variable_defined?("@#{key}") ? instance_variable_get("@#{key}") : default
|
11
|
+
end
|
12
|
+
|
13
|
+
def original_attributes
|
14
|
+
@_original_attributes
|
15
|
+
end
|
16
|
+
|
17
|
+
def raw_original_attributes
|
18
|
+
@_raw_original_attributes
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ResultClassComponents
|
4
|
+
module Comparison
|
5
|
+
def <=>(other)
|
6
|
+
return nil unless other.is_a?(self.class)
|
7
|
+
|
8
|
+
to_h <=> other.to_h
|
9
|
+
end
|
10
|
+
|
11
|
+
def ==(other)
|
12
|
+
return false unless other.is_a?(self.class)
|
13
|
+
|
14
|
+
to_h == other.to_h
|
15
|
+
end
|
16
|
+
|
17
|
+
def eql?(other)
|
18
|
+
self == other
|
19
|
+
end
|
20
|
+
|
21
|
+
def hash
|
22
|
+
to_h.hash
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ResultClassComponents
|
4
|
+
module KeyTransformation
|
5
|
+
def deep_stringify_keys(obj)
|
6
|
+
case obj
|
7
|
+
when Hash
|
8
|
+
obj.transform_keys(&:to_s)
|
9
|
+
.transform_values { |v| deep_stringify_keys(v) }
|
10
|
+
when Array
|
11
|
+
obj.map { |item| item.is_a?(Hash) ? deep_stringify_keys(item) : item }
|
12
|
+
else
|
13
|
+
obj
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def deep_symbolize_keys(obj)
|
18
|
+
case obj
|
19
|
+
when Hash
|
20
|
+
obj.transform_keys(&:to_sym)
|
21
|
+
.transform_values { |v| deep_symbolize_keys(v) }
|
22
|
+
when Array
|
23
|
+
obj.map { |item| item.is_a?(Hash) ? deep_symbolize_keys(item) : item }
|
24
|
+
else
|
25
|
+
obj
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ResultClassComponents
|
4
|
+
module Serialization
|
5
|
+
def to_h
|
6
|
+
attribute_names.each_with_object({}) do |name, hash|
|
7
|
+
value = instance_variable_get("@#{name}")
|
8
|
+
hash[name.to_s] = serialize_value(value)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_json(*args)
|
13
|
+
to_h.to_json(*args)
|
14
|
+
end
|
15
|
+
|
16
|
+
def deconstruct_keys(keys)
|
17
|
+
symbolized = deep_symbolize_keys(to_h)
|
18
|
+
keys ? symbolized.slice(*keys) : symbolized
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def serialize_value(value)
|
24
|
+
value.is_a?(Hash) ? deep_stringify_keys(value) : value
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ResultClassComponents
|
4
|
+
module TypeCasting
|
5
|
+
def type_cast_value(value, type_definition)
|
6
|
+
return value if type_definition.nil?
|
7
|
+
|
8
|
+
case type_definition
|
9
|
+
when Symbol
|
10
|
+
public_send(type_definition, value)
|
11
|
+
when Proc
|
12
|
+
type_definition.call(value)
|
13
|
+
when Class
|
14
|
+
type_definition.new.cast(value)
|
15
|
+
else
|
16
|
+
raise ArgumentError, "Unsupported type definition: #{type_definition}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "result_class_components/accessors"
|
4
|
+
require_relative "result_class_components/comparison"
|
5
|
+
require_relative "result_class_components/key_transformation"
|
6
|
+
require_relative "result_class_components/serialization"
|
7
|
+
require_relative "result_class_components/type_casting"
|
8
|
+
|
9
|
+
module Kweerie
|
10
|
+
class ResultClassGenerator
|
11
|
+
def self.generate(parent_class, attribute_names)
|
12
|
+
Class.new(parent_class) do
|
13
|
+
include Comparable
|
14
|
+
include ResultClassComponents::Accessors
|
15
|
+
include ResultClassComponents::Comparison
|
16
|
+
include ResultClassComponents::KeyTransformation
|
17
|
+
include ResultClassComponents::Serialization
|
18
|
+
include ResultClassComponents::TypeCasting
|
19
|
+
|
20
|
+
# Define attr_readers for all columns
|
21
|
+
attribute_names.each { |name| attr_reader name }
|
22
|
+
|
23
|
+
define_method :initialize do |attrs|
|
24
|
+
# Store both raw and casted versions
|
25
|
+
cast_definitions = parent_class.cast_definitions
|
26
|
+
|
27
|
+
@_raw_original_attributes = attrs.dup
|
28
|
+
@_original_attributes = attrs.each_with_object({}) do |(key, value), hash|
|
29
|
+
type_definition = cast_definitions[key.to_sym]
|
30
|
+
casted_value = type_cast_value(value, type_definition)
|
31
|
+
hash[key.to_s] = casted_value
|
32
|
+
instance_variable_set("@#{key}", casted_value)
|
33
|
+
end
|
34
|
+
|
35
|
+
super() if defined?(super)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Nice inspect output
|
39
|
+
define_method :inspect do
|
40
|
+
attrs = attribute_names.map do |name|
|
41
|
+
"#{name}=#{instance_variable_get("@#{name}").inspect}"
|
42
|
+
end.join(" ")
|
43
|
+
"#<#{self.class.superclass.name} #{attrs}>"
|
44
|
+
end
|
45
|
+
|
46
|
+
# Make attribute_names available to instance methods
|
47
|
+
define_method(:attribute_names) { attribute_names }
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class SQLPathResolver
|
4
|
+
class ConfigurationError < StandardError; end
|
5
|
+
class SQLFileNotFound < StandardError; end
|
6
|
+
|
7
|
+
def initialize(sql_file_location, name = nil)
|
8
|
+
@sql_file_location = sql_file_location
|
9
|
+
@name = name
|
10
|
+
end
|
11
|
+
|
12
|
+
def resolve
|
13
|
+
return resolve_root_path if root_path?
|
14
|
+
return resolve_relative_path if relative_path?
|
15
|
+
|
16
|
+
resolve_default_path
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
attr_reader :sql_file_location, :name
|
22
|
+
|
23
|
+
def root_path?
|
24
|
+
sql_file_location&.key?(:root)
|
25
|
+
end
|
26
|
+
|
27
|
+
def relative_path?
|
28
|
+
sql_file_location&.key?(:relative)
|
29
|
+
end
|
30
|
+
|
31
|
+
def resolve_root_path
|
32
|
+
raise ConfigurationError, "Root path requires Rails to be defined" unless defined?(Rails)
|
33
|
+
|
34
|
+
path = Rails.root.join(sql_file_location[:root]).to_s
|
35
|
+
validate_file_exists!(path)
|
36
|
+
path
|
37
|
+
end
|
38
|
+
|
39
|
+
def resolve_relative_path
|
40
|
+
relative_file = sql_file_location[:relative]
|
41
|
+
find_in_configured_paths(relative_file) or
|
42
|
+
raise SQLFileNotFound, "Could not find SQL file #{relative_file}"
|
43
|
+
end
|
44
|
+
|
45
|
+
def resolve_default_path
|
46
|
+
raise ArgumentError, "Name must be provided for default path resolution" unless name
|
47
|
+
|
48
|
+
sql_filename = "#{name.underscore}.sql"
|
49
|
+
find_in_configured_paths(sql_filename) or
|
50
|
+
raise SQLFileNotFound, "SQL file not found for #{name}"
|
51
|
+
end
|
52
|
+
|
53
|
+
def find_in_configured_paths(filename)
|
54
|
+
configured_paths.find do |path|
|
55
|
+
full_path = File.join(path, filename)
|
56
|
+
return full_path if File.exist?(full_path)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def configured_paths
|
61
|
+
Kweerie.configuration.sql_paths.call
|
62
|
+
end
|
63
|
+
|
64
|
+
def validate_file_exists!(path)
|
65
|
+
raise SQLFileNotFound, "Could not find SQL file at #{path}" unless File.exist?(path)
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Types
|
4
|
+
class Boolean
|
5
|
+
TRUTHY = [true, 1, "1", "t", "T", "true", "TRUE"].freeze
|
6
|
+
FALSEY = [false, 0, "0", "f", "F", "false", "FALSE"].freeze
|
7
|
+
|
8
|
+
def cast(value)
|
9
|
+
return nil if value.nil?
|
10
|
+
return value if value.is_a?(Boolean)
|
11
|
+
|
12
|
+
if TRUTHY.include?(value)
|
13
|
+
true
|
14
|
+
elsif FALSEY.include?(value)
|
15
|
+
false
|
16
|
+
else
|
17
|
+
raise ArgumentError, "Invalid boolean value: #{value}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Types
|
4
|
+
class PgArray
|
5
|
+
def cast(value)
|
6
|
+
clean_value = value.gsub(/^{|}$/, "")
|
7
|
+
return [] if clean_value.empty?
|
8
|
+
|
9
|
+
elements = clean_value.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
|
10
|
+
elements.map { |element| cast_array_element(element) }
|
11
|
+
end
|
12
|
+
|
13
|
+
def cast_array_element(element)
|
14
|
+
case element
|
15
|
+
when /^\d+$/ then element.to_i
|
16
|
+
when /^\d*\.\d+$/ then element.to_f
|
17
|
+
when /^(true|false)$/i then element.downcase == "true"
|
18
|
+
when /^"(.*)"$/ then ::Regexp.last_match(1)
|
19
|
+
else element
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Types
|
4
|
+
class PgJsonb
|
5
|
+
def cast(value)
|
6
|
+
return {} if value.nil?
|
7
|
+
return value if value.is_a?(Hash)
|
8
|
+
|
9
|
+
JSON.parse(value.to_s)
|
10
|
+
rescue JSON::ParserError
|
11
|
+
raise ArgumentError, "Invalid JSON value: #{value}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/kweerie/version.rb
CHANGED
data/lib/kweerie.rb
CHANGED
@@ -26,4 +26,9 @@ end
|
|
26
26
|
|
27
27
|
require_relative "kweerie/configuration"
|
28
28
|
require_relative "kweerie/base"
|
29
|
-
require_relative "kweerie/
|
29
|
+
require_relative "kweerie/base_object"
|
30
|
+
require_relative "kweerie/sql_path_resolver"
|
31
|
+
require_relative "kweerie/result_class_generator"
|
32
|
+
require_relative "kweerie/types/boolean"
|
33
|
+
require_relative "kweerie/types/pg_array"
|
34
|
+
require_relative "kweerie/types/pg_jsonb"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: kweerie
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Toby
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-11-
|
11
|
+
date: 2024-11-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: minitest
|
@@ -68,8 +68,18 @@ files:
|
|
68
68
|
- lib/generators/kweerie/kweerie_generator.rb
|
69
69
|
- lib/kweerie.rb
|
70
70
|
- lib/kweerie/base.rb
|
71
|
-
- lib/kweerie/
|
71
|
+
- lib/kweerie/base_object.rb
|
72
72
|
- lib/kweerie/configuration.rb
|
73
|
+
- lib/kweerie/result_class_components/accessors.rb
|
74
|
+
- lib/kweerie/result_class_components/comparison.rb
|
75
|
+
- lib/kweerie/result_class_components/key_transformation.rb
|
76
|
+
- lib/kweerie/result_class_components/serialization.rb
|
77
|
+
- lib/kweerie/result_class_components/type_casting.rb
|
78
|
+
- lib/kweerie/result_class_generator.rb
|
79
|
+
- lib/kweerie/sql_path_resolver.rb
|
80
|
+
- lib/kweerie/types/boolean.rb
|
81
|
+
- lib/kweerie/types/pg_array.rb
|
82
|
+
- lib/kweerie/types/pg_jsonb.rb
|
73
83
|
- lib/kweerie/version.rb
|
74
84
|
- sig/kweerie.rbs
|
75
85
|
homepage: https://github.com/tobyond/kweerie
|
data/lib/kweerie/base_objects.rb
DELETED
@@ -1,210 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "active_support/core_ext/string"
|
4
|
-
require "json"
|
5
|
-
|
6
|
-
module Kweerie
|
7
|
-
class BaseObjects < Base
|
8
|
-
class << self
|
9
|
-
def with(params = {})
|
10
|
-
results = super
|
11
|
-
return [] if results.empty?
|
12
|
-
|
13
|
-
# Create a unique result class for this query
|
14
|
-
result_class = generate_result_class(results.first.keys)
|
15
|
-
|
16
|
-
# Map results to objects
|
17
|
-
results.map { |row| result_class.new(row) }
|
18
|
-
end
|
19
|
-
|
20
|
-
private
|
21
|
-
|
22
|
-
def generate_result_class(attribute_names)
|
23
|
-
@generate_result_class ||= Class.new(self) do
|
24
|
-
# Include comparison and serialization modules
|
25
|
-
include Comparable
|
26
|
-
|
27
|
-
# Define attr_readers for all columns
|
28
|
-
attribute_names.each do |name|
|
29
|
-
attr_reader name
|
30
|
-
end
|
31
|
-
|
32
|
-
define_method :initialize do |attrs|
|
33
|
-
# Store both raw and casted versions
|
34
|
-
@_raw_original_attributes = attrs.dup
|
35
|
-
@_original_attributes = attrs.transform_keys(&:to_s).transform_values do |value|
|
36
|
-
type_cast_value(value)
|
37
|
-
end
|
38
|
-
|
39
|
-
attrs.each do |name, value|
|
40
|
-
casted_value = type_cast_value(value)
|
41
|
-
instance_variable_set("@#{name}", casted_value)
|
42
|
-
end
|
43
|
-
super() if defined?(super)
|
44
|
-
end
|
45
|
-
|
46
|
-
define_method :type_cast_value do |value|
|
47
|
-
case value
|
48
|
-
when /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/ # DateTime check
|
49
|
-
Time.parse(value)
|
50
|
-
when /^\d+$/ # Integer check
|
51
|
-
value.to_i
|
52
|
-
when /^\d*\.\d+$/ # Float check
|
53
|
-
value.to_f
|
54
|
-
when /^(true|false)$/i # Boolean check
|
55
|
-
value.downcase == "true"
|
56
|
-
when /^{.*}$/ # Could be PG array or JSON
|
57
|
-
if value.start_with?("{") && value.end_with?("}") && !value.include?('"=>') && !value.include?(": ")
|
58
|
-
# PostgreSQL array (simple heuristic: no "=>" or ":" suggests it's not JSON)
|
59
|
-
parse_pg_array(value)
|
60
|
-
else
|
61
|
-
# Attempt JSON parse
|
62
|
-
begin
|
63
|
-
parsed = JSON.parse(value)
|
64
|
-
deep_stringify_keys(parsed)
|
65
|
-
rescue JSON::ParserError
|
66
|
-
value
|
67
|
-
end
|
68
|
-
end
|
69
|
-
when /^[\[{]/ # Pure JSON (arrays starting with [ or other JSON objects)
|
70
|
-
begin
|
71
|
-
parsed = JSON.parse(value)
|
72
|
-
deep_stringify_keys(parsed)
|
73
|
-
rescue JSON::ParserError
|
74
|
-
value
|
75
|
-
end
|
76
|
-
else
|
77
|
-
value
|
78
|
-
end
|
79
|
-
end
|
80
|
-
|
81
|
-
define_method :parse_pg_array do |value|
|
82
|
-
# Remove the curly braces
|
83
|
-
clean_value = value.gsub(/^{|}$/, "")
|
84
|
-
return [] if clean_value.empty?
|
85
|
-
|
86
|
-
# Split on comma, but not within quoted strings
|
87
|
-
elements = clean_value.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
|
88
|
-
|
89
|
-
elements.map do |element|
|
90
|
-
case element
|
91
|
-
when /^\d+$/ # Integer
|
92
|
-
element.to_i
|
93
|
-
when /^\d*\.\d+$/ # Float
|
94
|
-
element.to_f
|
95
|
-
when /^(true|false)$/i # Boolean
|
96
|
-
element.downcase == "true"
|
97
|
-
when /^"(.*)"$/ # Quoted string
|
98
|
-
::Regexp.last_match(1)
|
99
|
-
else
|
100
|
-
element
|
101
|
-
end
|
102
|
-
end
|
103
|
-
end
|
104
|
-
|
105
|
-
define_method :deep_symbolize_keys do |obj|
|
106
|
-
case obj
|
107
|
-
when Hash
|
108
|
-
obj.transform_keys(&:to_sym).transform_values { |v| deep_symbolize_keys(v) }
|
109
|
-
when Array
|
110
|
-
obj.map { |item| item.is_a?(Hash) ? deep_symbolize_keys(item) : item }
|
111
|
-
else
|
112
|
-
obj
|
113
|
-
end
|
114
|
-
end
|
115
|
-
|
116
|
-
# Nice inspect output
|
117
|
-
define_method :inspect do
|
118
|
-
attrs = attribute_names.map do |name|
|
119
|
-
"#{name}=#{instance_variable_get("@#{name}").inspect}"
|
120
|
-
end.join(" ")
|
121
|
-
"#<#{self.class.superclass.name} #{attrs}>"
|
122
|
-
end
|
123
|
-
|
124
|
-
# Hash-like access
|
125
|
-
define_method :[] do |key|
|
126
|
-
instance_variable_get("@#{key}")
|
127
|
-
end
|
128
|
-
|
129
|
-
define_method :fetch do |key, default = nil|
|
130
|
-
instance_variable_defined?("@#{key}") ? instance_variable_get("@#{key}") : default
|
131
|
-
end
|
132
|
-
|
133
|
-
# Comparison methods
|
134
|
-
define_method :<=> do |other|
|
135
|
-
return nil unless other.is_a?(self.class)
|
136
|
-
|
137
|
-
to_h <=> other.to_h
|
138
|
-
end
|
139
|
-
|
140
|
-
define_method :== do |other|
|
141
|
-
return false unless other.is_a?(self.class)
|
142
|
-
|
143
|
-
to_h == other.to_h
|
144
|
-
end
|
145
|
-
|
146
|
-
define_method :eql? do |other|
|
147
|
-
self == other
|
148
|
-
end
|
149
|
-
|
150
|
-
define_method :hash do
|
151
|
-
to_h.hash
|
152
|
-
end
|
153
|
-
|
154
|
-
# Add helper method for deep string keys
|
155
|
-
define_method :deep_stringify_keys do |obj|
|
156
|
-
case obj
|
157
|
-
when Hash
|
158
|
-
obj.transform_keys(&:to_s).transform_values { |v| deep_stringify_keys(v) }
|
159
|
-
when Array
|
160
|
-
obj.map { |item| item.is_a?(Hash) ? deep_stringify_keys(item) : item }
|
161
|
-
else
|
162
|
-
obj
|
163
|
-
end
|
164
|
-
end
|
165
|
-
|
166
|
-
# Serialization
|
167
|
-
define_method :to_h do
|
168
|
-
attribute_names.each_with_object({}) do |name, hash|
|
169
|
-
value = instance_variable_get("@#{name}")
|
170
|
-
# Ensure string keys in output
|
171
|
-
hash[name.to_s] = value.is_a?(Hash) ? deep_stringify_keys(value) : value
|
172
|
-
end
|
173
|
-
end
|
174
|
-
|
175
|
-
define_method :to_json do |*args|
|
176
|
-
to_h.to_json(*args)
|
177
|
-
end
|
178
|
-
|
179
|
-
# Pattern matching support (Ruby 2.7+)
|
180
|
-
define_method :deconstruct_keys do |keys|
|
181
|
-
symbolized = deep_symbolize_keys(to_h)
|
182
|
-
keys ? symbolized.slice(*keys) : symbolized
|
183
|
-
end
|
184
|
-
|
185
|
-
# Original attributes access
|
186
|
-
define_method :original_attributes do
|
187
|
-
@_original_attributes
|
188
|
-
end
|
189
|
-
|
190
|
-
# Raw attributes access
|
191
|
-
define_method :raw_original_attributes do
|
192
|
-
@_raw_original_attributes
|
193
|
-
end
|
194
|
-
|
195
|
-
# ActiveModel-like changes tracking
|
196
|
-
define_method :changed? do
|
197
|
-
to_h != @_original_attributes
|
198
|
-
end
|
199
|
-
|
200
|
-
define_method :changes do
|
201
|
-
to_h.each_with_object({}) do |(key, value), changes|
|
202
|
-
original = @_original_attributes[key]
|
203
|
-
changes[key] = [original, value] if original != value
|
204
|
-
end
|
205
|
-
end
|
206
|
-
end
|
207
|
-
end
|
208
|
-
end
|
209
|
-
end
|
210
|
-
end
|