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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +36 -0
- data/Rakefile +8 -0
- data/app/validators/unique_items_validator.rb +40 -0
- data/config/locales/custom-predicates.en.yml +70 -0
- data/config/locales/dry-validation-errors.en.yml +113 -0
- data/config/locales/en.yml +14 -0
- data/config/routes.rb +4 -0
- data/lib/boot/setup.rb +151 -0
- data/lib/generators/hotstepper/service/USAGE +17 -0
- data/lib/generators/hotstepper/service/service_generator.rb +137 -0
- data/lib/generators/hotstepper/service/templates/operation.rb.tt +10 -0
- data/lib/generators/hotstepper/service/templates/service.rb.tt +39 -0
- data/lib/generators/hotstepper/typespace/USAGE +8 -0
- data/lib/generators/hotstepper/typespace/templates/types.rb.tt +5 -0
- data/lib/generators/hotstepper/typespace/typespace_generator.rb +25 -0
- data/lib/hotstepper/arel_ext/array_encoding.rb +66 -0
- data/lib/hotstepper/arel_ext/base_helpers.rb +220 -0
- data/lib/hotstepper/arel_ext/helpers.rb +17 -0
- data/lib/hotstepper/arel_ext/json_encoding.rb +49 -0
- data/lib/hotstepper/arel_ext/ltree_encoding.rb +52 -0
- data/lib/hotstepper/arel_ext/postgres_helpers.rb +34 -0
- data/lib/hotstepper/arel_ext/types.rb +37 -0
- data/lib/hotstepper/arel_ext/value_select_query.rb +142 -0
- data/lib/hotstepper/common.rb +52 -0
- data/lib/hotstepper/engine.rb +38 -0
- data/lib/hotstepper/models/association_helpers.rb +141 -0
- data/lib/hotstepper/models/checks_before_destruction.rb +35 -0
- data/lib/hotstepper/models/id_quoting.rb +23 -0
- data/lib/hotstepper/models/lazy_ordering.rb +57 -0
- data/lib/hotstepper/models/limit_to_one.rb +35 -0
- data/lib/hotstepper/models/lookup_helpers.rb +21 -0
- data/lib/hotstepper/models/materialized_view.rb +39 -0
- data/lib/hotstepper/models/model.rb +40 -0
- data/lib/hotstepper/models/querying.rb +55 -0
- data/lib/hotstepper/models/timestamp_scopes.rb +26 -0
- data/lib/hotstepper/models/view.rb +14 -0
- data/lib/hotstepper/models/where_matches.rb +67 -0
- data/lib/hotstepper/monadic/coercion.rb +111 -0
- data/lib/hotstepper/monadic/decorators.rb +61 -0
- data/lib/hotstepper/monadic/do_for.rb +22 -0
- data/lib/hotstepper/monadic.rb +8 -0
- data/lib/hotstepper/operation.rb +41 -0
- data/lib/hotstepper/patterns.rb +20 -0
- data/lib/hotstepper/query_building.rb +142 -0
- data/lib/hotstepper/service.rb +84 -0
- data/lib/hotstepper/steps/abstract_module.rb +21 -0
- data/lib/hotstepper/steps/implementation.rb +39 -0
- data/lib/hotstepper/steps/step.rb +26 -0
- data/lib/hotstepper/steps/watcher.rb +23 -0
- data/lib/hotstepper/steps/wrapper.rb +16 -0
- data/lib/hotstepper/struct.rb +30 -0
- data/lib/hotstepper/support/file_wrapper.rb +75 -0
- data/lib/hotstepper/support/generators/common.rb +39 -0
- data/lib/hotstepper/support/generators/namespaced.rb +23 -0
- data/lib/hotstepper/support/generators/service_operations.rb +14 -0
- data/lib/hotstepper/support/generators/step_definition.rb +45 -0
- data/lib/hotstepper/support/namespace_wrapper.rb +73 -0
- data/lib/hotstepper/support/null_service.rb +14 -0
- data/lib/hotstepper/support/types.rb +26 -0
- data/lib/hotstepper/testing/do_for.rb +23 -0
- data/lib/hotstepper/testing/operations.rb +88 -0
- data/lib/hotstepper/testing/typing.rb +18 -0
- data/lib/hotstepper/testing.rb +24 -0
- data/lib/hotstepper/typespace.rb +108 -0
- data/lib/hotstepper/typing.rb +69 -0
- data/lib/hotstepper/version.rb +5 -0
- data/lib/hotstepper.rb +56 -0
- data/lib/tasks/hotstepper_tasks.rake +6 -0
- data/lib/testing/contexts/operations.rb +13 -0
- data/lib/testing/matchers/00_disable_with.rb +22 -0
- data/lib/testing/matchers/dry_validation.rb +94 -0
- data/lib/testing/matchers/monadic.rb +107 -0
- data/lib/testing/matchers/negated.rb +8 -0
- 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
|