hotstepper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +36 -0
  4. data/Rakefile +8 -0
  5. data/app/validators/unique_items_validator.rb +40 -0
  6. data/config/locales/custom-predicates.en.yml +70 -0
  7. data/config/locales/dry-validation-errors.en.yml +113 -0
  8. data/config/locales/en.yml +14 -0
  9. data/config/routes.rb +4 -0
  10. data/lib/boot/setup.rb +151 -0
  11. data/lib/generators/hotstepper/service/USAGE +17 -0
  12. data/lib/generators/hotstepper/service/service_generator.rb +137 -0
  13. data/lib/generators/hotstepper/service/templates/operation.rb.tt +10 -0
  14. data/lib/generators/hotstepper/service/templates/service.rb.tt +39 -0
  15. data/lib/generators/hotstepper/typespace/USAGE +8 -0
  16. data/lib/generators/hotstepper/typespace/templates/types.rb.tt +5 -0
  17. data/lib/generators/hotstepper/typespace/typespace_generator.rb +25 -0
  18. data/lib/hotstepper/arel_ext/array_encoding.rb +66 -0
  19. data/lib/hotstepper/arel_ext/base_helpers.rb +220 -0
  20. data/lib/hotstepper/arel_ext/helpers.rb +17 -0
  21. data/lib/hotstepper/arel_ext/json_encoding.rb +49 -0
  22. data/lib/hotstepper/arel_ext/ltree_encoding.rb +52 -0
  23. data/lib/hotstepper/arel_ext/postgres_helpers.rb +34 -0
  24. data/lib/hotstepper/arel_ext/types.rb +37 -0
  25. data/lib/hotstepper/arel_ext/value_select_query.rb +142 -0
  26. data/lib/hotstepper/common.rb +52 -0
  27. data/lib/hotstepper/engine.rb +38 -0
  28. data/lib/hotstepper/models/association_helpers.rb +141 -0
  29. data/lib/hotstepper/models/checks_before_destruction.rb +35 -0
  30. data/lib/hotstepper/models/id_quoting.rb +23 -0
  31. data/lib/hotstepper/models/lazy_ordering.rb +57 -0
  32. data/lib/hotstepper/models/limit_to_one.rb +35 -0
  33. data/lib/hotstepper/models/lookup_helpers.rb +21 -0
  34. data/lib/hotstepper/models/materialized_view.rb +39 -0
  35. data/lib/hotstepper/models/model.rb +40 -0
  36. data/lib/hotstepper/models/querying.rb +55 -0
  37. data/lib/hotstepper/models/timestamp_scopes.rb +26 -0
  38. data/lib/hotstepper/models/view.rb +14 -0
  39. data/lib/hotstepper/models/where_matches.rb +67 -0
  40. data/lib/hotstepper/monadic/coercion.rb +111 -0
  41. data/lib/hotstepper/monadic/decorators.rb +61 -0
  42. data/lib/hotstepper/monadic/do_for.rb +22 -0
  43. data/lib/hotstepper/monadic.rb +8 -0
  44. data/lib/hotstepper/operation.rb +41 -0
  45. data/lib/hotstepper/patterns.rb +20 -0
  46. data/lib/hotstepper/query_building.rb +142 -0
  47. data/lib/hotstepper/service.rb +84 -0
  48. data/lib/hotstepper/steps/abstract_module.rb +21 -0
  49. data/lib/hotstepper/steps/implementation.rb +39 -0
  50. data/lib/hotstepper/steps/step.rb +26 -0
  51. data/lib/hotstepper/steps/watcher.rb +23 -0
  52. data/lib/hotstepper/steps/wrapper.rb +16 -0
  53. data/lib/hotstepper/struct.rb +30 -0
  54. data/lib/hotstepper/support/file_wrapper.rb +75 -0
  55. data/lib/hotstepper/support/generators/common.rb +39 -0
  56. data/lib/hotstepper/support/generators/namespaced.rb +23 -0
  57. data/lib/hotstepper/support/generators/service_operations.rb +14 -0
  58. data/lib/hotstepper/support/generators/step_definition.rb +45 -0
  59. data/lib/hotstepper/support/namespace_wrapper.rb +73 -0
  60. data/lib/hotstepper/support/null_service.rb +14 -0
  61. data/lib/hotstepper/support/types.rb +26 -0
  62. data/lib/hotstepper/testing/do_for.rb +23 -0
  63. data/lib/hotstepper/testing/operations.rb +88 -0
  64. data/lib/hotstepper/testing/typing.rb +18 -0
  65. data/lib/hotstepper/testing.rb +24 -0
  66. data/lib/hotstepper/typespace.rb +108 -0
  67. data/lib/hotstepper/typing.rb +69 -0
  68. data/lib/hotstepper/version.rb +5 -0
  69. data/lib/hotstepper.rb +56 -0
  70. data/lib/tasks/hotstepper_tasks.rake +6 -0
  71. data/lib/testing/contexts/operations.rb +13 -0
  72. data/lib/testing/matchers/00_disable_with.rb +22 -0
  73. data/lib/testing/matchers/dry_validation.rb +94 -0
  74. data/lib/testing/matchers/monadic.rb +107 -0
  75. data/lib/testing/matchers/negated.rb +8 -0
  76. metadata +377 -0
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ class TypespaceGenerator < Rails::Generators::Base
5
+ include Hotstepper::Support::Generators::Common
6
+ include Hotstepper::Support::Generators::Namespaced
7
+
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def prepare!
11
+ set_up_namespace!
12
+
13
+ @types = namespaced_file("Types")
14
+ end
15
+
16
+ def generate_type!
17
+ template "types.rb", types.to_service_path
18
+ end
19
+
20
+ private
21
+
22
+ # @return [Hotstepper::Support::Generators::FileWrapper]
23
+ attr_reader :types
24
+ end
25
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module ArelExt
5
+ # A module for encoding Postgres arrays in Arel expressions.
6
+ #
7
+ # It will cause errors when used with other databases, but only when the methods are called at runtime.
8
+ module ArrayEncoding
9
+ include BaseHelpers
10
+
11
+ class << self
12
+ def int_array
13
+ @int_array ||= ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(ActiveRecord::Type::Integer.new).freeze
14
+ end
15
+
16
+ def ltree_type
17
+ @ltree_type ||= ActiveRecord::ConnectionAdapters::PostgreSQL::OID::SpecializedString.new(:ltree)
18
+ end
19
+
20
+ def ltree_array
21
+ @ltree_array ||= ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(ltree_type).freeze
22
+ end
23
+
24
+ def lquery_type
25
+ @lquery_type ||= ActiveRecord::ConnectionAdapters::PostgreSQL::OID::SpecializedString.new(:lquery)
26
+ end
27
+
28
+ def lquery_array
29
+ @lquery_array ||= ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(lquery_type).freeze
30
+ end
31
+
32
+ def text_array
33
+ @text_array ||= ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(ActiveRecord::Type::Text.new).freeze
34
+ end
35
+ end
36
+
37
+ def arel_encode_enum_array(*elements, enum_type:)
38
+ arel_cast(arel_quote(Hotstepper::ArelExt::ArrayEncoding.lquery_array.serialize(elements.flatten)), "#{enum_type}[]")
39
+ end
40
+
41
+ def arel_encode_int_array(*elements)
42
+ arel_cast(arel_quote(Hotstepper::ArelExt::ArrayEncoding.int_array.serialize(elements.flatten)), "int[]")
43
+ end
44
+
45
+ def arel_encode_lquery_array(*elements)
46
+ arel_cast(arel_quote(Hotstepper::ArelExt::ArrayEncoding.lquery_array.serialize(elements.flatten)), "lquery[]")
47
+ end
48
+
49
+ # @return [Arel::Nodes::Quoted]
50
+ def arel_encode_ltree_array(*elements)
51
+ arel_cast(arel_quote(Hotstepper::ArelExt::ArrayEncoding.ltree_array.serialize(elements.flatten)), "ltree[]")
52
+ end
53
+
54
+ # @return [Arel::Nodes::Quoted]
55
+ def arel_encode_text_array(*elements)
56
+ arel_cast(arel_quote(Hotstepper::ArelExt::ArrayEncoding.text_array.serialize(elements.flatten)), "text[]")
57
+ end
58
+
59
+ def arel_value_in_array(value, array)
60
+ any_array = arel_named_fn("ANY", arel_quote(array))
61
+
62
+ arel_quote(value).eq(any_array)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module ArelExt
5
+ # Base helpers for building Arel expressions.
6
+ module BaseHelpers
7
+ # @param [Symbol, String] field
8
+ # @param [String] value
9
+ # @return [Arel::Nodes::Matches] an Arel expression that matches the field to the value with a `LIKE`
10
+ # query, with the value wrapped in `%` wildcards and with any `%` or `_` characters in the value escaped.
11
+ def arel_text_contains(field, value) = arel_table[field].matches("%#{escape_ilike_needle(value)}%")
12
+
13
+ # @param [String] needle
14
+ # @return [String] the needle with `%` and `_` characters escaped for use in a `LIKE` or `ILIKE` query.
15
+ def escape_ilike_needle(needle) = needle.gsub("%", "\\%").gsub("_", "\\_")
16
+
17
+ # @param [Object] left
18
+ # @param [Object] right
19
+ # @return [Arel::Nodes::As]
20
+ def arel_as(left, right) = Arel::Nodes::As.new(left, right)
21
+
22
+ # @param [Object] expr
23
+ # @param [String, nil] cast_to if provided, the SQL type to cast the expression to
24
+ # @return [Arel::Nodes::Case]
25
+ # @return [Arel::Nodes::NamedFunction("CAST")] when a `cast_to` expression is provided
26
+ def arel_case(expr = nil, cast_to: nil, &block)
27
+ statement = Arel::Nodes::Case.new(expr).tap(&block)
28
+
29
+ if cast_to.present?
30
+ arel_cast(statement, cast_to)
31
+ else
32
+ statement
33
+ end
34
+ end
35
+
36
+ # @param [Object] value
37
+ # @param [String] type the SQL type to cast to, e.g. "int", "text", "ltree"
38
+ # @return [Arel::Nodes::NamedFunction("CAST")]
39
+ def arel_cast(value, type) = arel_named_fn("CAST", arel_as(arel_quote(value), arel_literal(type)))
40
+
41
+ # @param [Array] exprs
42
+ # @return [Arel::Nodes::NamedFunction("COALESCE")]
43
+ def arel_coalesce(*exprs) = arel_named_fn("COALESCE", *exprs)
44
+
45
+ # A helper for building a case expression that works like if/else.
46
+ #
47
+ # @param [Object] boolean_expr an expression that evaluates to a boolean value
48
+ # @param [Object] truthy_expr the value to return if the boolean expression is true
49
+ # @param [Object] falsey_expr the value to return if the boolean expression is false
50
+ # @return [Arel::Nodes::Case]
51
+ def arel_if_else(boolean_expr, truthy_expr, falsey_expr)
52
+ arel_case do |stmt|
53
+ stmt.when(arel_quote(boolean_expr)).then(arel_quote(truthy_expr))
54
+
55
+ stmt.else(arel_quote(falsey_expr))
56
+ end
57
+ end
58
+
59
+ # @param [String] operator
60
+ # @param [Object] left
61
+ # @param [Object] right
62
+ # @return [Arel::Nodes::InfixOperation]
63
+ def arel_infix(operator, left, right) = Arel::Nodes::InfixOperation.new(operator, left, right)
64
+
65
+ # Encode a raw SQL string as an Arel node.
66
+ #
67
+ # @note Don't use with untrusted input!
68
+ # @param [String] value
69
+ # @return [Arel::Nodes::SqlLiteral]
70
+ def arel_literal(value) = Arel.sql(value)
71
+
72
+ # @param [String] name
73
+ # @param [Array] args
74
+ # @return [Arel::Nodes::NamedFunction]
75
+ def arel_named_fn(name, *args)
76
+ quoted_args = args.map { arel_quote(_1) }
77
+
78
+ Arel::Nodes::NamedFunction.new name, quoted_args
79
+ end
80
+
81
+ # @param [Arel::Nodes::Node] expression
82
+ # @return [Arel::Nodes::Not]
83
+ def arel_not(expression) = Arel::Nodes::Not.new expression
84
+
85
+ # @param [Object] arg
86
+ # @return [Arel::Nodes::Quoted] if arg is not already an Arel node
87
+ # @return [Arel::Nodes::Node] if arg is already an Arel node
88
+ def arel_quote(arg)
89
+ return arg if arg.kind_of?(Arel::Nodes::Node)
90
+
91
+ Arel::Nodes.build_quoted arg
92
+ end
93
+
94
+ # @see #arel_expr_in_query
95
+ # @param [Symbol] attr (see #arel_attrify)
96
+ # @param [#to_sql, #to_s] query
97
+ # @return [Arel::Nodes::In]
98
+ def arel_attr_in_query(attr, query) = arel_expr_in_query arel_attrify(attr), query
99
+
100
+ # @param [#in] expr
101
+ # @param [#to_sql, #to_s] query
102
+ # @return [Arel::Nodes::In]
103
+ def arel_expr_in_query(expr, query)
104
+ wrapped_query = arel_quote_query query
105
+
106
+ expr.in(wrapped_query)
107
+ end
108
+
109
+ # @param [#to_sql] query
110
+ # @return [Arel::Nodes::SqlLiteral]
111
+ def arel_quote_query(query)
112
+ case query
113
+ when Hotstepper::ArelExt::Types::ToSQL then arel_literal query.to_sql
114
+ when String then arel_literal query
115
+ else
116
+ # :nocov:
117
+ raise TypeError, "cannot quote query #{query.inspect}"
118
+ # :nocov:
119
+ end
120
+ end
121
+
122
+ # @param [Arel::Nodes::Node] value
123
+ # @return [Arel::Nodes::Grouping]
124
+ def arel_grouped(value) = Arel::Nodes::Grouping.new(value)
125
+
126
+ alias arel_grouping arel_grouped
127
+
128
+ # @param [Arel::OrderPredications] expression
129
+ # @param ["asc", "desc"] dir
130
+ # @param ["first", "last", nil] nulls
131
+ # @return [Arel::Nodes::Ordering]
132
+ def arel_apply_order_to(expression, dir, nulls: nil)
133
+ direction = Hotstepper::ArelExt::Types::SortDir[dir]
134
+
135
+ nulls_value = Hotstepper::ArelExt::Types::NullsDir[nulls || direction]
136
+
137
+ if direction == "desc"
138
+ expression.desc
139
+ else
140
+ expression.asc
141
+ end.then do |ordered|
142
+ if nulls_value == "first"
143
+ ordered.nulls_first
144
+ else
145
+ ordered.nulls_last
146
+ end
147
+ end
148
+ end
149
+
150
+ # Makes a more legible series of AND conditions.
151
+ # @return [Arel::Nodes::Grouping(Arel::Nodes::And)]
152
+ def arel_and_expressions(*expressions)
153
+ expressions.flatten!
154
+
155
+ return block_given? ? yield(expressions[0]) : expressions[0] if expressions.one?
156
+
157
+ expressions.reduce do |grouping, expression|
158
+ expression = yield expression if block_given?
159
+
160
+ next grouping if expression.blank?
161
+
162
+ if grouping.kind_of?(Arel::Nodes::Grouping)
163
+ grouping.expr.and(expression)
164
+ else
165
+ # First expression
166
+ grouping = yield grouping if block_given?
167
+
168
+ grouping.and(expression)
169
+ end
170
+ end
171
+ end
172
+
173
+ # Makes a more legible series of OR conditions.
174
+ # @return [Arel::Nodes::Grouping(Arel::Nodes::Or)]
175
+ def arel_or_expressions(*expressions)
176
+ expressions.flatten!
177
+
178
+ return block_given? ? yield(expressions[0]) : expressions[0] if expressions.one?
179
+
180
+ expressions.reduce do |grouping, expression|
181
+ expression = yield expression if block_given?
182
+
183
+ next grouping if expression.blank?
184
+
185
+ if grouping.kind_of?(Arel::Nodes::Grouping)
186
+ grouping.expr.or(expression)
187
+ else
188
+ # First expression
189
+ grouping = yield grouping if block_given?
190
+
191
+ grouping.or(expression)
192
+ end
193
+ end
194
+ end
195
+
196
+ # @param [String, Symbol, Arel::Attributes::Attribute, Arel::Nodes::SqlLiteral, Arel::Expresions, Arel::Node] attribute
197
+ # @return [Arel::Attributes::Attribute]
198
+ # @return [Arel::Nodes::SqlLiteral]
199
+ # @return [Arel::Node]
200
+ def arel_attrify(attribute)
201
+ case attribute
202
+ when Arel::Attributes::Attribute, Arel::Nodes::SqlLiteral, Arel::Expressions, Arel::Nodes::Node
203
+ attribute
204
+ when arel_column_matcher
205
+ arel_table[attribute]
206
+ when /\A[^.\s]+\.[^.\s]+/
207
+ arel_literal attribute
208
+ else
209
+ raise TypeError, "Don't know how to turn #{attribute} into an Arel::Attribute"
210
+ end
211
+ end
212
+
213
+ # @!scope private
214
+ # @return [Regexp]
215
+ def arel_column_matcher
216
+ @arel_column_matcher ||= /\A#{Regexp.union(column_names)}\z/
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module ArelExt
5
+ # Helpers for building Arel expressions.
6
+ #
7
+ # @note This should be `extend`ed on the model class as it is not a concern,
8
+ # it is sometimes used in instance methods on query building classes.
9
+ module Helpers
10
+ include Hotstepper::ArelExt::BaseHelpers
11
+ include Hotstepper::ArelExt::ArrayEncoding
12
+ include Hotstepper::ArelExt::JsonEncoding
13
+ include Hotstepper::ArelExt::LtreeEncoding
14
+ include Hotstepper::ArelExt::PostgresHelpers
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module ArelExt
5
+ # A module for encoding JSON values in Arel expressions with Postgres syntax.
6
+ #
7
+ # The syntax will not work in other databases, but it should not raise errors
8
+ # unless actually passed to ActiveRecord.
9
+ module JsonEncoding
10
+ include BaseHelpers
11
+
12
+ def arel_json_contains(attribute, **values)
13
+ arel_infix "@>", arel_attrify(attribute), arel_cast(values.to_json, "jsonb")
14
+ end
15
+
16
+ def arel_json_has_key(attribute, key)
17
+ arel_infix ??, arel_attrify(attribute), arel_quote(key)
18
+ end
19
+
20
+ def arel_json_get_path(attribute, *path_parts)
21
+ raise "Must have at least one part" if path_parts.blank?
22
+
23
+ arel_infix "#>", arel_attrify(attribute), arel_encode_text_array(*path_parts)
24
+ end
25
+
26
+ def arel_json_get_path_as_text(attribute, *path_parts)
27
+ raise "Must have at least one part" if path_parts.blank?
28
+
29
+ arel_infix "#>>", arel_attrify(attribute), arel_encode_text_array(*path_parts)
30
+ end
31
+
32
+ def arel_json_get(attribute, key)
33
+ arel_infix "->", arel_attrify(attribute), arel_quote(key)
34
+ end
35
+
36
+ def arel_json_get_as_text(attribute, key)
37
+ arel_infix "->>", arel_attrify(attribute), arel_quote(key)
38
+ end
39
+
40
+ def arel_json_cast_property(attribute, key, type)
41
+ arel_cast(arel_json_get_as_text(attribute, key), type)
42
+ end
43
+
44
+ def arel_json_partial_update(attribute, **values)
45
+ arel_infix "||", arel_attrify(attribute), arel_cast(values.to_json, "jsonb")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module ArelExt
5
+ # A module for working with postgres' `ltree` extension in Arel.
6
+ #
7
+ # The syntax will not work in other databases, but it should not raise errors
8
+ # unless actually passed to ActiveRecord.
9
+ module LtreeEncoding
10
+ include BaseHelpers
11
+
12
+ # @param [Object] value
13
+ # @return [Arel::Nodes::NamedFunction("CAST")]
14
+ def arel_ltree(value) = arel_cast(value, "ltree")
15
+
16
+ # @param [String, Arel::Attribute] left
17
+ # @param [Object] right
18
+ # @return [Arel::Nodes::InfixOperation]
19
+ def arel_ltree_contains(left, right)
20
+ arel_infix "@>", arel_attrify(left), arel_quote(right)
21
+ end
22
+
23
+ # @param [String, Arel::Attribute] left
24
+ # @param [Object] right
25
+ # @return [Arel::Nodes::InfixOperation]
26
+ def arel_ltree_matches(left, right)
27
+ arel_infix ?~, arel_attrify(left), arel_quote(right)
28
+ end
29
+
30
+ # @param [String, Arel::Attribute] attribute
31
+ # @param [String, Array<String>, Hotstepper::ArelExt::Types::ManyArray] queries
32
+ # @return [Arel::Nodes::InfixOperation]
33
+ def arel_ltree_matches_any_query(attribute, *queries)
34
+ arel_infix(??, arel_attrify(attribute), arel_encode_lquery_array(*queries))
35
+ end
36
+
37
+ # @param [String, Arel::Attribute] left
38
+ # @param [String, Array<String>, Hotstepper::ArelExt::Types::ManyArray] right
39
+ # @return [Arel::Nodes::InfixOperation]
40
+ def arel_ltree_matches_one_or_any(left, right)
41
+ case right
42
+ when Hotstepper::ArelExt::Types::ManyArray
43
+ arel_ltree_matches_any_query left, *right
44
+ when Array
45
+ arel_ltree_matches left, right.first
46
+ else
47
+ arel_ltree_matches left, right
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module ArelExt
5
+ # Methods that specifically rely on Postgres syntax.
6
+ module PostgresHelpers
7
+ include BaseHelpers
8
+
9
+ # @param [String, Symbol, Arel::Attributes::Attribute] column
10
+ # @param [String] attribute
11
+ # @return [Arel::Nodes::Grouping] an expression that accesses the given attribute of a Postgres composite type.
12
+ def arel_composite_attr(column, attribute)
13
+ composite = arel_grouping(arel_attrify(column))
14
+
15
+ arel_literal("(%s.#{attribute})" % [composite.to_sql])
16
+ end
17
+
18
+ # @param [String, Symbol, Arel::Attributes::Attribute] attr_or_column
19
+ # @param [String] input
20
+ # @return [Arel::Nodes::Or(Arel::Nodes::InfixOperation("%"), Arel::Nodes::InfixOperation("%>"))] a condition that matches either the trigram similarity or the trigram match
21
+ def arel_trigram_match(attr_or_column, input)
22
+ attr = arel_attrify attr_or_column
23
+
24
+ quoted = arel_quote input
25
+
26
+ trigrams = arel_infix(?%, attr, quoted)
27
+
28
+ similarity = arel_infix("%>", attr, quoted)
29
+
30
+ trigrams.or(similarity)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module ArelExt
5
+ # A module for custom types used when matching Arel expressions.
6
+ module Types
7
+ extend Hotstepper::Typespace
8
+
9
+ # @return [Array] An array of at least two elements of any type.
10
+ ManyArray = Array.of(Any).constrained(min_size: 2)
11
+
12
+ # @return [Dry::Types::Type(String)]
13
+ NullsDir = Coercible::String.fallback("last").enum("last", "first").constructor do |raw|
14
+ value = raw.to_s.downcase
15
+
16
+ case value
17
+ when "asc" then "last"
18
+ when "desc" then "first"
19
+ else
20
+ value
21
+ end
22
+ end
23
+
24
+ SortDir = Coercible::String.fallback("asc").enum("asc", "desc").constructor do |value|
25
+ value.to_s.downcase
26
+ end
27
+
28
+ ToSQL = Interface(:to_sql)
29
+
30
+ ValueSelectCasts = Hash.map(Coercible::Symbol, Coercible::Symbol)
31
+
32
+ ValueSelectValue = Hash.map(Coercible::Symbol, Any).constrained(min_size: 1)
33
+
34
+ ValueSelectValues = Array.of(ValueSelectValue)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module ArelExt
5
+ # A helper for building a `SELECT * FROM (VALUES (...)), ... ) AS tbl (col1, col2, ...)` query with proper type casts.
6
+ #
7
+ # @todo Make connection configurable if needed.
8
+ class ValueSelectQuery
9
+ include Dry::Initializer[undefined: false].define -> do
10
+ param :values, ::Hotstepper::ArelExt::Types::ValueSelectValues
11
+
12
+ option :casts, ::Hotstepper::ArelExt::Types::ValueSelectCasts, default: proc { Dry::Core::Constants::EMPTY_HASH }
13
+ option :table_name, ::Hotstepper::ArelExt::Types::String, default: proc { "tbl" }
14
+ option :model_class, ::Hotstepper::Support::Models::Types::ModelClass.optional, optional: true
15
+ end
16
+
17
+ DEFAULT_TYPE = ActiveRecord::Type::Value.new.freeze
18
+
19
+ # We won't bother casting columns with these types,
20
+ # since PG will figure it out.
21
+ SKIPPABLE_CASTS = [
22
+ nil,
23
+ :boolean,
24
+ :string,
25
+ :citext,
26
+ :text
27
+ ].freeze
28
+
29
+ # @return [Hash{Symbol => Symbol}]
30
+ attr_reader :column_casts
31
+
32
+ # @return [Array<Symbol>]
33
+ attr_reader :column_names
34
+
35
+ # @return [Hash{Symbol => ActiveRecord::Type}]
36
+ attr_reader :column_types
37
+
38
+ # @return [<String>]
39
+ attr_reader :quoted_columns
40
+
41
+ # @return [<<Arel::Nodes::SqlLiteral>>]
42
+ attr_reader :quoted_values
43
+
44
+ # @return [Arel::Table]
45
+ attr_reader :table
46
+
47
+ # @return [Arel::Nodes::ValuesList]
48
+ attr_reader :values_list
49
+
50
+ def initialize(...)
51
+ super
52
+
53
+ compile_columns!
54
+
55
+ compile_values_list!
56
+
57
+ compile_query!
58
+ end
59
+
60
+ def to_sql = @query.to_sql
61
+
62
+ private
63
+
64
+ # @return [void]
65
+ def compile_columns!
66
+ @column_names = values.reduce([]) { |n, v| n | v.keys }
67
+
68
+ @column_types = column_names.index_with { |name| model_class&.type_for_attribute(name.to_s) || DEFAULT_TYPE }
69
+
70
+ @column_casts = column_names.index_with { |name| normalize_cast(casts[name] || default_column_cast_for(name)) }
71
+
72
+ @quoted_columns = column_names.map { |name| quote_column(name) }
73
+
74
+ @table = Arel::Table.new(table_name)
75
+ end
76
+
77
+ # @return [void]
78
+ def compile_values_list!
79
+ @quoted_values = quote_values
80
+
81
+ @values_list = Arel::Nodes::Grouping.new(Arel::Nodes::ValuesList.new(quoted_values))
82
+ end
83
+
84
+ def compile_query!
85
+ source = Arel::Nodes::As.new(values_list, Arel.sql("#{table_name}(#{quoted_columns.join(", ")})"))
86
+
87
+ projections = column_names.map do |name|
88
+ cast = column_casts[name]
89
+
90
+ quoted = quote_column(name)
91
+
92
+ next quoted unless cast
93
+
94
+ Arel.sql("CAST(#{quoted} AS #{cast}) AS #{quoted}")
95
+ end
96
+
97
+ @query = table.project(*projections).from(source)
98
+ end
99
+
100
+ def connection = ActiveRecord::Base.connection
101
+
102
+ # @param [Symbol] attribute
103
+ # @return [String, nil]
104
+ def default_column_cast_for(attribute)
105
+ return unless model_class
106
+
107
+ column = model_class.columns_hash[attribute.to_s]
108
+
109
+ stm = column&.sql_type_metadata
110
+
111
+ return if stm.blank? || stm.type.in?(SKIPPABLE_CASTS)
112
+
113
+ stm.sql_type
114
+ end
115
+
116
+ def normalize_cast(input)
117
+ case input
118
+ when :json then :jsonb
119
+ when :string, :citext then nil
120
+ else input
121
+ end
122
+ end
123
+
124
+ def quote_column(name) = connection.quote_column_name(name)
125
+
126
+ def quote_value(value) = connection.quote(value)
127
+
128
+ # @return [<<Arel::Nodes::SqlLiteral>>]
129
+ def quote_values
130
+ values.map do |value_hash|
131
+ column_names.map do |name|
132
+ serialized = column_types[name].serialize(value_hash[name])
133
+
134
+ quoted = quote_value(serialized)
135
+
136
+ Arel.sql(quoted)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotstepper
4
+ module Common
5
+ # A container resolver that looks up keys in the `Hotstepper.config.common.container`.
6
+ #
7
+ # It does not memoize results, so it will work correctly in development mode / Rails reloading.
8
+ #
9
+ # @api private
10
+ class Resolver < Dry::Container::Resolver
11
+ def call(_, key)
12
+ common_container = Hotstepper.config.common.container or raise MissingCommonContainerError
13
+
14
+ common_container.resolve(key)
15
+ end
16
+ end
17
+
18
+ # A container that will defer to whatever the current value of the `Hotstepper.config.common.container`
19
+ # is at the time of resolution. This allows operations that include the `CallsOperation` mixin to call
20
+ # operations from the host application's container, without needing to worry about how that container is set up.
21
+ #
22
+ # In development, this will allow the value to be automatically reset as the host application reloads.
23
+ module Container
24
+ extend Dry::Container::Mixin
25
+
26
+ config.resolver = Resolver.new
27
+ end
28
+
29
+ # dry-auto_inject mixin for the common container.
30
+ Deps = Dry::AutoInject(Container)
31
+
32
+ # Common operation mixin for Rails applications.
33
+ #
34
+ # This is intended for allowing easy interop with the host Rails application, so long as it
35
+ # hooks its container into `Hotstepper.config.common.container`. If the host application is
36
+ # using dry-rails, this will automatically be set up by the engine.
37
+ module CallsOperation
38
+ extend ActiveSupport::Concern
39
+ extend Hotstepper::Monadic::Decorators
40
+
41
+ # Calls an operation from the common container.
42
+ #
43
+ # @see Hotstepper::Common::Container
44
+ # @param [String, Symbol] name
45
+ # @param [Object] ... all other args will be passed onto the operation's `call` method
46
+ # @return [Object] the return value of the operation's `call` method
47
+ monadic_operation! def call_operation(name, ...)
48
+ Hotstepper::Common::Container[name].(...)
49
+ end
50
+ end
51
+ end
52
+ end