kweerie 0.1.5 → 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 +3 -0
- data/CHANGELOG.md +6 -0
- data/README.md +89 -83
- data/lib/kweerie/base.rb +3 -36
- 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
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -55,10 +55,10 @@ results = UserSearch.with(
|
|
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
|
@@ -148,7 +148,7 @@ 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
|
@@ -158,65 +158,15 @@ users = AllUsers.all
|
|
158
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
164
|
# => [#<AllUsers id=1 name="Eclipsoid">, ...]
|
165
165
|
```
|
166
166
|
|
167
|
-
### Automatic Type Casting
|
168
|
-
|
169
|
-
BaseObjects automatically casts common PostgreSQL types to their Ruby equivalents:
|
170
|
-
|
171
|
-
```ruby
|
172
|
-
# In your SQL file
|
173
|
-
SELECT
|
174
|
-
name,
|
175
|
-
created_at, -- timestamp
|
176
|
-
age::integer, -- integer
|
177
|
-
score::float, -- float
|
178
|
-
active::boolean, -- boolean
|
179
|
-
metadata::jsonb, -- jsonb
|
180
|
-
tags::jsonb -- jsonb array
|
181
|
-
FROM users;
|
182
|
-
|
183
|
-
# In your Ruby code
|
184
|
-
user = UserSearch.with(name: 'Eclipsoid').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"
|
196
|
-
```
|
197
|
-
|
198
|
-
### Pattern Matching Support
|
199
|
-
|
200
|
-
BaseObjects support Ruby's pattern matching syntax:
|
201
|
-
|
202
|
-
```ruby
|
203
|
-
case user
|
204
|
-
in { name:, metadata: { role: "admin" } }
|
205
|
-
puts "Admin user: #{name}"
|
206
|
-
in { name:, metadata: { role: "user" } }
|
207
|
-
puts "Regular user: #{name}"
|
208
|
-
end
|
209
|
-
|
210
|
-
# Nested pattern matching
|
211
|
-
case user
|
212
|
-
in { metadata: { preferences: { theme: "dark" } } }
|
213
|
-
puts "Dark theme user"
|
214
|
-
end
|
215
|
-
```
|
216
|
-
|
217
167
|
### Object Interface
|
218
168
|
|
219
|
-
|
169
|
+
BaseObject provide several useful methods:
|
220
170
|
|
221
171
|
```ruby
|
222
172
|
# Hash-like access
|
@@ -230,31 +180,6 @@ user.to_json # => JSON string
|
|
230
180
|
# Comparison
|
231
181
|
user1 == user2 # Compare all attributes
|
232
182
|
users.sort_by(&:created_at) # Sortable
|
233
|
-
|
234
|
-
# Change tracking
|
235
|
-
user.changed? # => Check if any attributes changed
|
236
|
-
user.changes # => Hash of changes with [old, new] values
|
237
|
-
user.original_attributes # => Original attributes from DB
|
238
|
-
```
|
239
|
-
|
240
|
-
### PostgreSQL Array Support
|
241
|
-
|
242
|
-
BaseObjects handles PostgreSQL arrays by converting them to Ruby arrays with proper type casting:
|
243
|
-
|
244
|
-
```ruby
|
245
|
-
# In your PostgreSQL schema
|
246
|
-
create_table :users do |t|
|
247
|
-
t.integer :preferred_ordering, array: true, default: []
|
248
|
-
t.string :tags, array: true
|
249
|
-
t.float :scores, array: true
|
250
|
-
end
|
251
|
-
|
252
|
-
# In your query
|
253
|
-
user = UserSearch.with(name: 'Eclipsoid').first
|
254
|
-
|
255
|
-
user.preferred_ordering # => [1, 3, 2]
|
256
|
-
user.tags # => ["ruby", "rails"]
|
257
|
-
user.scores # => [98.5, 87.2, 92.0]
|
258
183
|
```
|
259
184
|
|
260
185
|
### SQL File Location
|
@@ -282,9 +207,90 @@ class UserSearch < Kweerie::Base
|
|
282
207
|
end
|
283
208
|
```
|
284
209
|
|
210
|
+
## Type Casting
|
211
|
+
|
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
|
215
|
+
|
216
|
+
```ruby
|
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
|
+
```
|
223
|
+
|
224
|
+
### Casting Options
|
225
|
+
|
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
|
235
|
+
|
236
|
+
# Postgres Array casting
|
237
|
+
cast_select :tags, as: Types::PgArray
|
238
|
+
```
|
239
|
+
|
240
|
+
#### Lambda/Proc Casting
|
241
|
+
For simple transformations, you can use a lambda or proc:
|
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:
|
253
|
+
|
254
|
+
```ruby
|
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
|
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:
|
269
|
+
|
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
|
278
|
+
|
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
|
289
|
+
```
|
290
|
+
|
285
291
|
### Performance Considerations
|
286
292
|
|
287
|
-
|
293
|
+
BaseObject creates a unique class for each query result set, with the following optimizations:
|
288
294
|
|
289
295
|
- Classes are cached and reused for subsequent queries
|
290
296
|
- Attribute readers are defined upfront
|
@@ -325,9 +331,9 @@ end
|
|
325
331
|
|
326
332
|
## Requirements
|
327
333
|
|
328
|
-
- Ruby
|
334
|
+
- Ruby 3 or higher
|
329
335
|
- PostgreSQL (this gem is PostgreSQL-specific and uses the `pg` gem)
|
330
|
-
- Rails
|
336
|
+
- Rails 7+ (optional, needed for the generator and default ActiveRecord integration)
|
331
337
|
|
332
338
|
## Features
|
333
339
|
|
data/lib/kweerie/base.rb
CHANGED
@@ -100,40 +100,7 @@ module Kweerie
|
|
100
100
|
end
|
101
101
|
|
102
102
|
def sql_path
|
103
|
-
@sql_path ||=
|
104
|
-
if @sql_file_location&.key?(:root)
|
105
|
-
raise ConfigurationError, "Root path requires Rails to be defined" unless defined?(Rails)
|
106
|
-
|
107
|
-
path = Rails.root.join(@sql_file_location[:root]).to_s
|
108
|
-
raise SQLFileNotFound, "Could not find SQL file at #{path}" unless File.exist?(path)
|
109
|
-
|
110
|
-
path
|
111
|
-
|
112
|
-
elsif @sql_file_location&.key?(:relative)
|
113
|
-
configured_paths = Kweerie.configuration.sql_paths.call
|
114
|
-
sql_file = configured_paths.map do |path|
|
115
|
-
full_path = File.join(path, @sql_file_location[:relative])
|
116
|
-
full_path if File.exist?(full_path)
|
117
|
-
end.compact.first
|
118
|
-
unless sql_file
|
119
|
-
raise SQLFileNotFound,
|
120
|
-
"Could not find SQL file #{@sql_file_location[:relative]}"
|
121
|
-
end
|
122
|
-
|
123
|
-
sql_file
|
124
|
-
else # default behavior
|
125
|
-
sql_filename = "#{name.underscore}.sql"
|
126
|
-
configured_paths = Kweerie.configuration.sql_paths.call
|
127
|
-
|
128
|
-
sql_file = configured_paths.map do |path|
|
129
|
-
full_path = File.join(path, sql_filename)
|
130
|
-
full_path if File.exist?(full_path)
|
131
|
-
end.compact.first
|
132
|
-
|
133
|
-
raise SQLFileNotFound, "SQL file not found for #{name}" unless sql_file
|
134
|
-
|
135
|
-
sql_file
|
136
|
-
end
|
103
|
+
@sql_path ||= SQLPathResolver.new(@sql_file_location, name).resolve
|
137
104
|
end
|
138
105
|
|
139
106
|
def sql_content
|
@@ -151,7 +118,7 @@ module Kweerie
|
|
151
118
|
#
|
152
119
|
# === Returns
|
153
120
|
#
|
154
|
-
# Array of hashes representing the query results. When using Kweerie::
|
121
|
+
# Array of hashes representing the query results. When using Kweerie::BaseObject,
|
155
122
|
# returns array of typed objects instead.
|
156
123
|
#
|
157
124
|
# === Examples
|
@@ -163,7 +130,7 @@ module Kweerie
|
|
163
130
|
# )
|
164
131
|
# # => [{"id"=>1, "name"=>"Eclipsoid", "email"=>"eclipsoid@example.com"}]
|
165
132
|
#
|
166
|
-
# # With type casting (
|
133
|
+
# # With type casting (BaseObject)
|
167
134
|
# UserSearch.with(created_after: '2024-01-01')
|
168
135
|
# # => [#<UserSearch id=1 created_at=2024-01-01 00:00:00 +0000>]
|
169
136
|
#
|
@@ -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
|