inquery 1.0.10 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rubocop.yml +1 -1
  3. data/.github/workflows/ruby.yml +24 -8
  4. data/.gitignore +2 -2
  5. data/.rubocop.yml +4 -0
  6. data/Appraisals +24 -4
  7. data/CHANGELOG.md +47 -1
  8. data/Gemfile +17 -1
  9. data/Gemfile.lock +125 -0
  10. data/LICENSE +1 -1
  11. data/MIGRATION.md +260 -0
  12. data/README.md +31 -9
  13. data/RUBY_VERSION +1 -1
  14. data/Rakefile +4 -11
  15. data/VERSION +1 -1
  16. data/doc/Inquery/Exceptions/Base.html +4 -4
  17. data/doc/Inquery/Exceptions/InvalidRelation.html +4 -4
  18. data/doc/Inquery/Exceptions/UnknownCallSignature.html +4 -4
  19. data/doc/Inquery/Exceptions.html +4 -4
  20. data/doc/Inquery/MethodAccessibleHash.html +431 -0
  21. data/doc/Inquery/Mixins/RawSqlUtils.html +17 -4
  22. data/doc/Inquery/Mixins/RelationValidation/ClassMethods.html +8 -7
  23. data/doc/Inquery/Mixins/RelationValidation.html +9 -8
  24. data/doc/Inquery/Mixins/SchemaValidation/ClassMethods.html +20 -6
  25. data/doc/Inquery/Mixins/SchemaValidation.html +4 -4
  26. data/doc/Inquery/Mixins.html +4 -4
  27. data/doc/Inquery/Query/Chainable.html +207 -90
  28. data/doc/Inquery/Query.html +401 -73
  29. data/doc/Inquery.html +121 -6
  30. data/doc/_index.html +12 -5
  31. data/doc/class_list.html +6 -3
  32. data/doc/css/full_list.css +3 -3
  33. data/doc/css/style.css +6 -0
  34. data/doc/file.README.html +118 -17
  35. data/doc/file_list.html +5 -2
  36. data/doc/frames.html +10 -5
  37. data/doc/index.html +118 -17
  38. data/doc/js/app.js +294 -264
  39. data/doc/js/full_list.js +30 -4
  40. data/doc/method_list.html +52 -17
  41. data/doc/top-level-namespace.html +4 -4
  42. data/gemfiles/rails_5.2.gemfile +1 -0
  43. data/gemfiles/rails_6.0.gemfile +1 -0
  44. data/gemfiles/rails_6.1.gemfile +1 -0
  45. data/gemfiles/rails_7.0.gemfile +1 -0
  46. data/gemfiles/{rails_5.1.gemfile → rails_7.1.gemfile} +2 -1
  47. data/gemfiles/rails_7.2.gemfile +8 -0
  48. data/gemfiles/rails_8.0.gemfile +8 -0
  49. data/gemfiles/rails_8.1.gemfile +8 -0
  50. data/inquery.gemspec +11 -34
  51. data/lib/inquery/method_accessible_hash.rb +45 -0
  52. data/lib/inquery/mixins/raw_sql_utils.rb +32 -6
  53. data/lib/inquery/mixins/relation_validation.rb +1 -1
  54. data/lib/inquery/mixins/schema_validation.rb +8 -1
  55. data/lib/inquery/query/chainable.rb +69 -27
  56. data/lib/inquery/query.rb +67 -14
  57. data/lib/inquery.rb +9 -0
  58. data/test/inquery/error_handling_test.rb +117 -0
  59. data/test/inquery/method_accessible_hash_test.rb +85 -0
  60. data/test/inquery/mixins/raw_sql_utils_test.rb +67 -0
  61. data/test/inquery/query/chainable_test.rb +78 -0
  62. data/test/inquery/query_test.rb +86 -0
  63. data/test/test_helper.rb +11 -0
  64. metadata +30 -129
  65. data/.yardopts +0 -1
data/lib/inquery/query.rb CHANGED
@@ -1,24 +1,54 @@
1
1
  module Inquery
2
+ # Base query class that encapsulates database queries in reusable classes.
3
+ #
4
+ # Subclasses must implement the 'call' method to define the query logic.
5
+ # Optionally, override 'process' to transform the query results.
6
+ #
7
+ # Example:
8
+ # class FetchActiveUsers < Inquery::Query
9
+ # def call
10
+ # User.where(active: true)
11
+ # end
12
+ # end
13
+ #
14
+ # FetchActiveUsers.run # => Returns active users
2
15
  class Query
3
16
  include Mixins::SchemaValidation
4
17
  include Mixins::RawSqlUtils
5
18
 
6
19
  attr_reader :params
7
20
 
8
- # Instantiates the query class using the given arguments
9
- # and runs `call` and `process` on it.
21
+ # Instantiates the query class with the given params and executes both
22
+ # 'call' and 'process' methods.
23
+ #
24
+ # This is the primary way to execute queries. It runs the query logic
25
+ # defined in 'call' and then passes the results through 'process' for
26
+ # any post-processing.
27
+ #
28
+ # @param args [Hash] Parameters to pass to the query
29
+ # @return [Object] The processed query results
30
+ # @raise [Schemacop::Exceptions::ValidationError] if params don't match schema
10
31
  def self.run(*args)
11
32
  new(*args).run
12
33
  end
13
34
 
14
- # Instantiates the query class using the given arguments
15
- # and runs `call` on it.
35
+ # Instantiates the query class with the given params and executes only
36
+ # the 'call' method, skipping post-processing.
37
+ #
38
+ # @param args [Hash] Parameters to pass to the query
39
+ # @return [Object] The raw query results
40
+ # @raise [Schemacop::Exceptions::ValidationError] if params don't match schema
16
41
  def self.call(*args)
17
42
  new(*args).call
18
43
  end
19
44
 
20
- # Instantiates the query class and validates the given params hash (if there
21
- # was a validation schema specified).
45
+ # Initializes a new query instance with the given parameters.
46
+ #
47
+ # If a schema is defined using 'schema' or 'schema3', the params will be
48
+ # validated against it before the query is executed.
49
+ #
50
+ # @param params [Hash] Parameters for the query
51
+ # @raise [Schemacop::Exceptions::ValidationError] if params don't match schema
22
52
  def initialize(params = {})
23
53
  @params = params
24
54
 
@@ -27,29 +57,52 @@ module Inquery
27
57
  end
28
58
  end
29
59
 
30
- # Runs both `call` and `process`.
60
+ # Executes the query by calling 'call' and then 'process'.
61
+ #
62
+ # @return [Object] The processed query results
31
63
  def run
32
64
  process(call)
33
65
  end
34
66
 
35
- # Override this method to perform the actual query.
67
+ # Override this method in subclasses to define the query logic.
68
+ #
69
+ # @return [Object] Query results (typically an ActiveRecord::Relation)
70
+ # @raise [NotImplementedError] if not overridden in subclass
36
71
  def call
37
72
  fail NotImplementedError
38
73
  end
39
74
 
40
- # Override this method to perform an optional result postprocessing.
75
+ # Override this method in subclasses to transform the query results.
76
+ #
77
+ # By default, returns the results unchanged. Common uses include
78
+ # converting to JSON, counting records, or extracting specific fields.
79
+ #
80
+ # @param results [Object] The results from the 'call' method
81
+ # @return [Object] The processed results
41
82
  def process(results)
42
83
  results
43
84
  end
44
85
 
45
- # Returns a copy of the query's params, wrapped in an OpenStruct object for
46
- # easyer access.
86
+ # Returns the query params wrapped in a MethodAccessibleHash for
87
+ # convenient access using dot notation.
88
+ #
89
+ # Example:
90
+ # schema3 { str! :name }
91
+ # def call
92
+ # User.where(name: osparams.name) # Access via dot notation
93
+ # end
94
+ #
95
+ # @return [MethodAccessibleHash] Params with method access
47
96
  def osparams
48
- @osparams ||= OpenStruct.new(params)
97
+ @osparams ||= MethodAccessibleHash.new(params)
49
98
  end
50
99
 
51
- # Provides a connection to the database. May be overridden if a different
52
- # connection is desired. Defaults to `ActiveRecord::Base.connection`.
100
+ # Returns the database connection to use for this query.
101
+ #
102
+ # Override this method if you need to use a different connection than
103
+ # the default ActiveRecord connection.
104
+ #
105
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
53
106
  def connection
54
107
  ActiveRecord::Base.connection
55
108
  end
data/lib/inquery.rb CHANGED
@@ -1,10 +1,19 @@
1
1
  module Inquery
2
+ mattr_accessor :default_schema_version
3
+ self.default_schema_version = 2
4
+
5
+ # Setup method that should be called in a dedicated initializer.
6
+ def self.setup
7
+ yield self
8
+ end
2
9
  end
3
10
 
4
11
  require 'uri'
12
+ require 'logger'
5
13
  require 'schemacop'
6
14
 
7
15
  require 'inquery/exceptions'
16
+ require 'inquery/method_accessible_hash'
8
17
  require 'inquery/mixins/schema_validation'
9
18
  require 'inquery/mixins/relation_validation'
10
19
  require 'inquery/mixins/raw_sql_utils'
@@ -0,0 +1,117 @@
1
+ require 'test_helper'
2
+
3
+ module Inquery
4
+ class ErrorHandlingTest < Minitest::Test
5
+ include TestHelper
6
+
7
+ def setup
8
+ self.class.setup_db
9
+ self.class.setup_base_data
10
+ end
11
+
12
+ def test_query_without_call_method_raises_not_implemented_error
13
+ query_class = Class.new(Inquery::Query)
14
+
15
+ assert_raises(NotImplementedError) do
16
+ query_class.run
17
+ end
18
+ end
19
+
20
+ def test_chainable_query_without_call_method_raises_not_implemented_error
21
+ query_class = Class.new(Inquery::Query::Chainable) do
22
+ relation class: 'User'
23
+ end
24
+
25
+ assert_raises(NotImplementedError) do
26
+ query_class.run(User.all)
27
+ end
28
+ end
29
+
30
+ def test_invalid_relation_class_raises_error
31
+ assert_raises(NameError) do
32
+ Class.new(Inquery::Query::Chainable) do
33
+ relation class: 'NonExistentClass'
34
+
35
+ def call
36
+ relation
37
+ end
38
+ end.run
39
+ end
40
+ end
41
+
42
+ def test_invalid_relation_type_raises_error
43
+ query_class = Class.new(Inquery::Query::Chainable) do
44
+ relation class: 'User'
45
+
46
+ def call
47
+ relation
48
+ end
49
+ end
50
+
51
+ assert_raises(Inquery::Exceptions::UnknownCallSignature) do
52
+ query_class.run('not a relation')
53
+ end
54
+ end
55
+
56
+ def test_schema_validation_failure_with_missing_required_param
57
+ query_class = Class.new(Inquery::Query) do
58
+ schema3 do
59
+ str! :name
60
+ int! :age
61
+ end
62
+
63
+ def call
64
+ User.all
65
+ end
66
+ end
67
+
68
+ assert_raises(Schemacop::Exceptions::ValidationError) do
69
+ query_class.run(name: 'Alice')
70
+ end
71
+ end
72
+
73
+ def test_schema_validation_failure_with_wrong_type
74
+ query_class = Class.new(Inquery::Query) do
75
+ schema3 do
76
+ int! :age
77
+ end
78
+
79
+ def call
80
+ User.all
81
+ end
82
+ end
83
+
84
+ assert_raises(Schemacop::Exceptions::ValidationError) do
85
+ query_class.run(age: 'not a number')
86
+ end
87
+ end
88
+
89
+ def test_invalid_relation_field_count
90
+ query_class = Class.new(Inquery::Query::Chainable) do
91
+ relation fields: 1
92
+
93
+ def call
94
+ relation
95
+ end
96
+ end
97
+
98
+ # Passing a relation with more than 1 field should raise an error
99
+ assert_raises(Inquery::Exceptions::InvalidRelation) do
100
+ query_class.run(User.select(:id, :name))
101
+ end
102
+ end
103
+
104
+ def test_unknown_call_signature_raises_error
105
+ query_class = Class.new(Inquery::Query::Chainable) do
106
+ def call
107
+ relation
108
+ end
109
+ end
110
+
111
+ # Passing invalid arguments should raise UnknownCallSignature
112
+ assert_raises(Inquery::Exceptions::UnknownCallSignature) do
113
+ query_class.new(123, 456, 789)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,85 @@
1
+ require 'test_helper'
2
+
3
+ module Inquery
4
+ class MethodAccessibleHashTest < Minitest::Test
5
+ def test_new
6
+ hash = MethodAccessibleHash.new(name: 'John Smith', age: 70, 'pension' => 300)
7
+
8
+ assert_equal 'John Smith', hash.name
9
+ assert_equal 70, hash.age
10
+ assert_equal 300, hash.pension
11
+ end
12
+
13
+ def test_new_no_arguments
14
+ assert_equal '{}', MethodAccessibleHash.new.to_s
15
+ end
16
+
17
+ def test_to_h
18
+ assert MethodAccessibleHash.new.merge(foo: :bar).to_h.instance_of?(::Hash)
19
+ end
20
+
21
+ def test_getter
22
+ hash = MethodAccessibleHash.new.merge(foo: :bar, bar: :baz)
23
+ assert_equal :bar, hash.foo
24
+ assert_equal :baz, hash.bar
25
+ end
26
+
27
+ def test_setter
28
+ hash = MethodAccessibleHash.new.merge(foo: :bar)
29
+ assert_equal :bar, hash.foo
30
+
31
+ hash.foo = :x
32
+ hash.bar = :y
33
+
34
+ assert_equal :x, hash.foo
35
+ assert_equal :y, hash.bar
36
+ end
37
+
38
+ def test_reference
39
+ hash = MethodAccessibleHash.new
40
+ hash.foo = 42
41
+ assert_equal 42, hash[:foo]
42
+ assert_equal 42, hash.foo
43
+ end
44
+
45
+ def test_comparison
46
+ assert_equal({ foo: :bar }, MethodAccessibleHash.new(foo: :bar))
47
+ refute_equal({ foo: :bar, bar: :baz }, MethodAccessibleHash.new(foo: :bar))
48
+ end
49
+
50
+ def test_frozen
51
+ hash = MethodAccessibleHash.new(name: 'John Smith', age: 70, pension: 300).freeze
52
+
53
+ assert_equal 70, hash.age
54
+ assert_equal 300, hash.pension
55
+ assert_equal 'John Smith', hash.name
56
+
57
+ assert_raises RuntimeError do
58
+ hash.age = 42
59
+ end
60
+
61
+ assert_raises RuntimeError do
62
+ hash.foo = 42
63
+ end
64
+
65
+ clone = hash.clone
66
+ assert clone.frozen?
67
+ assert_equal 70, clone.age
68
+
69
+ assert_raises RuntimeError do
70
+ clone.age = 42
71
+ end
72
+
73
+ assert_raises RuntimeError do
74
+ clone.foo = 42
75
+ end
76
+
77
+ duplicate = hash.dup
78
+ refute duplicate.frozen?
79
+
80
+ assert_equal 70, duplicate.age
81
+ assert_equal 300, duplicate.pension
82
+ assert_equal 'John Smith', duplicate.name
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,67 @@
1
+ require 'test_helper'
2
+
3
+ module Inquery
4
+ module Mixins
5
+ class RawSqlUtilsTest < Minitest::Test
6
+ include TestHelper
7
+
8
+ def setup
9
+ self.class.setup_db
10
+ self.class.setup_base_data
11
+ end
12
+
13
+ # Test query class
14
+ class TestQuery < Inquery::Query
15
+ def call
16
+ # Intentionally empty
17
+ end
18
+ end
19
+
20
+ def test_san_sanitizes_sql_with_variables
21
+ query = TestQuery.new
22
+ result = query.san('SELECT * FROM users WHERE id = ?', 1)
23
+
24
+ assert_kind_of String, result
25
+ assert_includes result, 'SELECT * FROM users WHERE id = 1'
26
+ end
27
+
28
+ def test_san_handles_multiple_variables
29
+ query = TestQuery.new
30
+ result = query.san('SELECT * FROM users WHERE name = ? AND id = ?', 'Alice', 1)
31
+
32
+ assert_kind_of String, result
33
+ assert_includes result, 'SELECT * FROM users WHERE'
34
+ assert_includes result, 'Alice'
35
+ assert_includes result, '1'
36
+ end
37
+
38
+ def test_san_handles_special_characters
39
+ query = TestQuery.new
40
+ result = query.san('SELECT * FROM users WHERE name = ?', "O'Brien")
41
+
42
+ assert_kind_of String, result
43
+ # ActiveRecord escapes quotes using SQL standard (doubled quotes)
44
+ assert_includes result, "O''Brien"
45
+ end
46
+
47
+ def test_exec_query_executes_sql
48
+ query = TestQuery.new
49
+ sql = query.san('SELECT * FROM users WHERE id = ?', 1)
50
+ result = query.exec_query(sql)
51
+
52
+ assert_kind_of ActiveRecord::Result, result
53
+ assert_equal 1, result.length
54
+ assert_equal 1, result.first['id']
55
+ end
56
+
57
+ def test_exec_query_returns_multiple_rows
58
+ query = TestQuery.new
59
+ sql = 'SELECT * FROM users ORDER BY id'
60
+ result = query.exec_query(sql)
61
+
62
+ assert_kind_of ActiveRecord::Result, result
63
+ assert_operator result.length, :>, 1
64
+ end
65
+ end
66
+ end
67
+ end
@@ -62,6 +62,84 @@ module Inquery
62
62
  result = Queries::Group::FilterWithColor.run(Group.where('id > 2'), color: 'green')
63
63
  assert_equal Group.find([3]), result.to_a
64
64
  end
65
+
66
+ def test_call_not_implemented_error
67
+ # Create an anonymous chainable query class that doesn't override call
68
+ query_class = Class.new(Inquery::Query::Chainable) do
69
+ relation class: 'Group'
70
+ end
71
+
72
+ # Should raise NotImplementedError when call is not overridden
73
+ assert_raises(NotImplementedError) do
74
+ query_class.run(Group.all)
75
+ end
76
+ end
77
+
78
+ def test_chainable_with_default_relation
79
+ query_class = Class.new(Inquery::Query::Chainable) do
80
+ relation class: 'Group'
81
+
82
+ def call
83
+ relation.where(color: 'red')
84
+ end
85
+ end
86
+
87
+ # Should use default relation when no relation is passed
88
+ result = query_class.run
89
+ assert_equal Group.find([1]), result.to_a
90
+ end
91
+
92
+ def test_chainable_with_params_only
93
+ result = Queries::Group::FilterWithColor.run(color: 'red')
94
+ assert_equal Group.find([1]), result.to_a
95
+ end
96
+
97
+ def test_chainable_with_empty_relation
98
+ query_class = Class.new(Inquery::Query::Chainable) do
99
+ relation class: 'Group'
100
+
101
+ def call
102
+ relation.where(color: 'blue')
103
+ end
104
+ end
105
+
106
+ result = query_class.run(Group.where('1=0'))
107
+ assert_empty result.to_a
108
+ end
109
+
110
+ def test_chainable_with_different_call_signatures
111
+ query_class = Class.new(Inquery::Query::Chainable) do
112
+ relation class: 'Group'
113
+ schema3 do
114
+ str? :color
115
+ end
116
+
117
+ def call
118
+ if osparams.color
119
+ relation.where(color: osparams.color)
120
+ else
121
+ relation
122
+ end
123
+ end
124
+ end
125
+
126
+ # Test with relation and params
127
+ result1 = query_class.run(Group.all, color: 'red')
128
+ assert_equal Group.find([1]), result1.to_a
129
+
130
+ # Test with params only
131
+ result2 = query_class.run(color: 'green')
132
+ assert_equal Group.find([2, 3]), result2.to_a
133
+
134
+ # Test with relation only (should return all)
135
+ result3 = query_class.run(Group.where('id > 1'))
136
+ assert_equal Group.find([2, 3]), result3.to_a
137
+ end
138
+
139
+ def test_relation_returns_active_record_relation
140
+ result = Queries::Group::FetchRed.run
141
+ assert_kind_of ActiveRecord::Relation, result
142
+ end
65
143
  end
66
144
  end
67
145
  end
@@ -43,5 +43,91 @@ module Inquery
43
43
  result = Queries::Group::FetchAsJson.run
44
44
  assert_equal Group.all.to_json, result
45
45
  end
46
+
47
+ def test_query_with_optional_params
48
+ query_class = Class.new(Inquery::Query) do
49
+ schema3 do
50
+ str! :name
51
+ int? :age
52
+ end
53
+
54
+ def call
55
+ User.where(name: osparams.name)
56
+ end
57
+ end
58
+
59
+ # With both params
60
+ result = query_class.run(name: 'Alice', age: 30)
61
+ assert_kind_of ActiveRecord::Relation, result
62
+
63
+ # With only required param
64
+ result = query_class.run(name: 'Alice')
65
+ assert_kind_of ActiveRecord::Relation, result
66
+ end
67
+
68
+ def test_query_without_schema
69
+ query_class = Class.new(Inquery::Query) do
70
+ def call
71
+ User.all
72
+ end
73
+ end
74
+
75
+ result = query_class.run
76
+ assert_equal User.all, result
77
+ end
78
+
79
+ def test_query_with_empty_result
80
+ query_class = Class.new(Inquery::Query) do
81
+ def call
82
+ User.where('1=0')
83
+ end
84
+ end
85
+
86
+ result = query_class.run
87
+ assert_empty result.to_a
88
+ end
89
+
90
+ def test_query_returns_nil
91
+ query_class = Class.new(Inquery::Query) do
92
+ def call
93
+ nil
94
+ end
95
+ end
96
+
97
+ result = query_class.run
98
+ assert_nil result
99
+ end
100
+
101
+ def test_query_with_process_method
102
+ query_class = Class.new(Inquery::Query) do
103
+ def call
104
+ User.all
105
+ end
106
+
107
+ def process(results)
108
+ results.count
109
+ end
110
+ end
111
+
112
+ result = query_class.run
113
+ assert_equal 3, result
114
+ end
115
+
116
+ def test_osparams_returns_method_accessible_hash
117
+ query_class = Class.new(Inquery::Query) do
118
+ schema3 do
119
+ str! :name
120
+ end
121
+
122
+ def call
123
+ osparams
124
+ end
125
+ end
126
+
127
+ result = query_class.run(name: 'Alice')
128
+ assert_kind_of Inquery::MethodAccessibleHash, result
129
+ assert_equal 'Alice', result.name
130
+ assert_equal 'Alice', result[:name]
131
+ end
46
132
  end
47
133
  end
data/test/test_helper.rb CHANGED
@@ -1,3 +1,14 @@
1
+ begin
2
+ require 'simplecov'
3
+ SimpleCov.start do
4
+ add_filter '/test/'
5
+ add_filter '/vendor/'
6
+ end
7
+ rescue LoadError
8
+ # SimpleCov not available, skip coverage reporting
9
+ end
10
+
11
+ require 'logger'
1
12
  require 'active_record'
2
13
  require 'minitest/autorun'
3
14
  require 'inquery'