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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8c4f949ad0ee144a37015f2444ad19721390ee94a59b1fc118599409b6b0f0a
4
- data.tar.gz: a8b741b11e8dd572f6fccba9f06becbb75b06f0444e879f43b21f70d164d2ba7
3
+ metadata.gz: a1ca73e0c3e5890aa9805eed0b491c100be177b958fc9bab9592eaf4f2cdb0d0
4
+ data.tar.gz: b79729e675821e02c2ff02e3dfcdb8e5237230364790cc9e59e1d4a953ea8da6
5
5
  SHA512:
6
- metadata.gz: 368206d1376231a56d197911245878f8efdf8eb33f86f47135c079f2d3c28b2ac5a70786b7bafaa694b980f6f192ea6ac2d43ffcdb41f77bbd24aee295c7b9c4
7
- data.tar.gz: 82c8cb0e9a1171f3a0e90b0db23ebc5f35e2b82401f4db2066b3903db383b8e08f619ab25edefbc0bea4ebab93fde9072d86895bfa864a1f793c4370f5f02f03
6
+ metadata.gz: 511ea093375cca24abbb8514a98b26cfb662239b836c3da7140aac4ef06ba7a23bf40074e1d9a973ac56a1cd02d1dd5dac486409968bed8f40b39312b6a8ac6d
7
+ data.tar.gz: abd8e02e09a00f3d4dd683c5e9e179386c053ed0c390d2f09a8c227866e39ff6303400766f2ff173eaf5d379e3bcad84840621a2fdde5b593c76e8643c72741a
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.6
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
@@ -1,4 +1,10 @@
1
1
  ## [Unreleased]
2
+ ## [0.2.0] - 2024-11-12
3
+
4
+ - changed BaseObjects to BaseObject.
5
+ - remove automatic type inference.
6
+ - added cast_select.
7
+ - added tiny amound of custom types.
2
8
 
3
9
  ## [0.1.0] - 2024-11-06
4
10
 
data/README.md CHANGED
@@ -47,30 +47,30 @@ Execute your query:
47
47
 
48
48
  ```ruby
49
49
  results = UserSearch.with(
50
- name: 'John%',
50
+ name: 'Eclipsoid%',
51
51
  email: '%@example.com'
52
52
  )
53
- # => [{"id"=>9981, "name"=>"John Doe", "email"=>"johndoe@example.com"}]
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::BaseObjects` to get typed Ruby objects with proper attribute methods:
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::BaseObjects
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: 'Claude',
68
+ name: 'Eclipsoid',
69
69
  created_at: '2024-01-01'
70
70
  )
71
71
 
72
72
  user = users.first
73
- user.name # => "Claude"
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::BaseObjects`, returning arrays of hashes or objects respectively:
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" => "Claude"}, ...]
158
+ # => [{"id" => 1, "name" => "Eclipsoid"}, ...]
159
159
 
160
160
  # Returns array of objects
161
- class AllUsers < Kweerie::BaseObjects
161
+ class AllUsers < Kweerie::BaseObject
162
162
  end
163
163
  users = AllUsers.all
164
- # => [#<AllUsers id=1 name="Claude">, ...]
164
+ # => [#<AllUsers id=1 name="Eclipsoid">, ...]
165
165
  ```
166
166
 
167
- ### Automatic Type Casting
167
+ ### Object Interface
168
168
 
169
- BaseObjects automatically casts common PostgreSQL types to their Ruby equivalents:
169
+ BaseObject provide several useful methods:
170
170
 
171
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: '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
- ### Pattern Matching Support
185
+ ### SQL File Location
199
186
 
200
- BaseObjects support Ruby's pattern matching syntax:
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
- case user
204
- in { name:, metadata: { role: "admin" } }
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
- # Nested pattern matching
211
- case user
212
- in { metadata: { preferences: { theme: "dark" } } }
213
- puts "Dark theme user"
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
- ### Object Interface
210
+ ## Type Casting
218
211
 
219
- BaseObjects provide several useful methods:
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
- # Hash-like access
223
- user[:name] # => "Claude"
224
- user.fetch(:email, 'N/A') # => Returns 'N/A' if email is nil
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
- # Serialization
227
- user.to_h # => Hash with string keys
228
- user.to_json # => JSON string
224
+ ### Casting Options
229
225
 
230
- # Comparison
231
- user1 == user2 # Compare all attributes
232
- users.sort_by(&:created_at) # Sortable
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
- # 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
236
+ # Postgres Array casting
237
+ cast_select :tags, as: Types::PgArray
238
238
  ```
239
239
 
240
- ### PostgreSQL Array Support
240
+ #### Lambda/Proc Casting
241
+ For simple transformations, you can use a lambda or proc:
241
242
 
242
- BaseObjects handles PostgreSQL arrays by converting them to Ruby arrays with proper type casting:
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
- # 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
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
- # In your query
253
- user = UserSearch.with(name: 'Claude').first
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
- user.preferred_ordering # => [1, 3, 2]
256
- user.tags # => ["ruby", "rails"]
257
- user.scores # => [98.5, 87.2, 92.0]
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
- BaseObjects creates a unique class for each query result set, with the following optimizations:
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 2.7 or higher
334
+ - Ruby 3 or higher
304
335
  - PostgreSQL (this gem is PostgreSQL-specific and uses the `pg` gem)
305
- - Rails 6+ (optional, needed for the generator and default ActiveRecord integration)
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
- def sql_path
18
- @sql_path ||= begin
19
- subclass_file = "#{name.underscore}.sql"
20
- possible_paths = Kweerie.configuration.sql_paths.call
21
-
22
- sql_file = possible_paths.map do |path|
23
- File.join(root_path, path, subclass_file)
24
- end.find { |f| File.exist?(f) }
25
-
26
- unless sql_file
27
- raise SQLFileNotFound,
28
- "Could not find SQL file for #{name} in paths: #{possible_paths.join(", ")}"
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
- sql_file
32
- end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kweerie
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
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/base_objects"
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.1.4
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-07 00:00:00.000000000 Z
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/base_objects.rb
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
@@ -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