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,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
initializer "hotstepper.i18n" do
|
|
6
|
+
ActiveSupport.on_load(:i18n) do
|
|
7
|
+
I18n.backend.eager_load!
|
|
8
|
+
|
|
9
|
+
[Dry::Schema, Dry::Validation::Contract].each do |lib|
|
|
10
|
+
lib.config.messages.backend = :i18n
|
|
11
|
+
lib.config.messages.load_paths << Hotstepper::Engine.root.join("config", "locales", "custom-predicates.en.yml").realpath
|
|
12
|
+
lib.config.messages.load_paths << Rails.root.join("config", "locales", "en.yml").realpath
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "hotstepper.models" do
|
|
18
|
+
ActiveSupport.on_load(:active_record) do
|
|
19
|
+
extend Hotstepper::ArelExt::Helpers
|
|
20
|
+
|
|
21
|
+
include Hotstepper::Models::Model
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if defined?(ApplicationRecord)
|
|
25
|
+
Hotstepper.config.db.connection_class = ApplicationRecord
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Wire up the `Hotstepper::Common` infrastructure.
|
|
30
|
+
initializer "hotstepper.operation_integration" do
|
|
31
|
+
config.to_prepare do
|
|
32
|
+
Hotstepper.configure do |config|
|
|
33
|
+
config.common.container = Dry::Rails::Railtie.container
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
# Helpers for defining certain types of associations.
|
|
6
|
+
module AssociationHelpers
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
include Hotstepper::Models::LookupHelpers
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
# @param [Symbol] name
|
|
13
|
+
# @param [Class] model_klass
|
|
14
|
+
# @return [Boolean] whether the association with the given name
|
|
15
|
+
def associated_against?(name, model_klass)
|
|
16
|
+
has_association_for?(name) { _1.klass == model_klass }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Delegate a scope to an association.
|
|
20
|
+
#
|
|
21
|
+
# * For `has_many` associations, it will use a subquery to filter
|
|
22
|
+
# the parent model by the associated records matching the delegated scope.
|
|
23
|
+
# This avoids the cartesian issue with joining against many child records.
|
|
24
|
+
# * For `has_one` and `belongs_to` associations, it will use an `INNER JOIN`
|
|
25
|
+
# and merge the delegated scope directly.
|
|
26
|
+
# @param [Symbol] name
|
|
27
|
+
# @param [Symbol] association_name
|
|
28
|
+
# @param [Symbol] target_name
|
|
29
|
+
# @return [void]
|
|
30
|
+
def delegate_scope!(name, association_name, target_name: name)
|
|
31
|
+
scope_definition = ->(*args, **kwargs) do
|
|
32
|
+
association = reflect_on_association(association_name)
|
|
33
|
+
|
|
34
|
+
delegated = association.klass.public_send(target_name, *args, **kwargs)
|
|
35
|
+
|
|
36
|
+
if association.has_one? || association.belongs_to?
|
|
37
|
+
joins(association_name).merge(delegated)
|
|
38
|
+
else
|
|
39
|
+
where(id: delegated.select(association.foreign_key))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
scope name, scope_definition
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @see #delegate_scope!
|
|
47
|
+
# @param [<Symbol>] names the names of the scopes to delegate
|
|
48
|
+
# @param [Symbol] to the association to delegate to
|
|
49
|
+
# @return [void]
|
|
50
|
+
def delegate_scopes!(*names, to:)
|
|
51
|
+
# :nocov:
|
|
52
|
+
names.each do |scope|
|
|
53
|
+
delegate_scope! name, to
|
|
54
|
+
end
|
|
55
|
+
# :nocov:
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param [Symbol] name
|
|
59
|
+
# @param [Symbol] association_name
|
|
60
|
+
# @param [Symbol] scope_name
|
|
61
|
+
# @return [void]
|
|
62
|
+
def delegate_lookup!(name, association_name, scope_name: :"lookup_by_#{name}")
|
|
63
|
+
delegate_scope! scope_name, association_name
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @see #delegate_lookup!
|
|
67
|
+
# @param [<Symbol>] names the names of the lookups to delegate
|
|
68
|
+
# @param [Symbol] to the association to delegate to
|
|
69
|
+
# @return [void]
|
|
70
|
+
def delegate_lookups!(*names, to:)
|
|
71
|
+
names.each do |name|
|
|
72
|
+
delegate_lookup! name, to
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Define a read-only `belongs_to` association
|
|
77
|
+
#
|
|
78
|
+
# @note This is more of a semantic association type in order to avoid the need
|
|
79
|
+
# to declare what to do with dependents or inverse associations when they
|
|
80
|
+
# are not necessary.
|
|
81
|
+
# @param [Symbol] name
|
|
82
|
+
# @param [<Object>] args
|
|
83
|
+
# @param [{ Symbol => Object }] kwargs
|
|
84
|
+
# @yield Set up the belongs_to association
|
|
85
|
+
# @yieldreturn [void]
|
|
86
|
+
# @!macro [attach] belongs_to_readonly
|
|
87
|
+
# @!parse ruby
|
|
88
|
+
# # @note This association is read-only (likely against a database view).
|
|
89
|
+
# belongs_to $1, -> { readonly }, ${2--1}
|
|
90
|
+
def belongs_to_readonly(name, *args, **kwargs, &)
|
|
91
|
+
belongs_to(name, *args, **kwargs, &)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param [Symbol] association_name
|
|
95
|
+
# @yieldreturn [Boolean]
|
|
96
|
+
# @return [Boolean] whether the model has an association with the given name
|
|
97
|
+
def has_association_for?(association_name)
|
|
98
|
+
assoc = reflect_on_association(association_name)
|
|
99
|
+
|
|
100
|
+
return false if assoc.blank?
|
|
101
|
+
|
|
102
|
+
block_given? ? yield(assoc) : true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Define a read-only `has_many` association.
|
|
106
|
+
#
|
|
107
|
+
# @note This is more of a semantic association type in order to avoid the need
|
|
108
|
+
# to declare what to do with dependents or inverse associations when they
|
|
109
|
+
# are not necessary.
|
|
110
|
+
# @param [Symbol] name
|
|
111
|
+
# @param [<Object>] args
|
|
112
|
+
# @param [{ Symbol => Object }] kwargs
|
|
113
|
+
# @yield Set up the has_many association
|
|
114
|
+
# @yieldreturn [void]
|
|
115
|
+
# @!macro [attach] has_many_readonly
|
|
116
|
+
# @!parse ruby
|
|
117
|
+
# # @note This association is read-only (likely against a database view).
|
|
118
|
+
# has_many $1, -> { readonly }, ${2--1}
|
|
119
|
+
def has_many_readonly(name, *args, **kwargs, &)
|
|
120
|
+
has_many(name, *args, **kwargs, &)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Define a read-only `has_one` association.
|
|
124
|
+
#
|
|
125
|
+
# @note This is more of a semantic association type in order to avoid the need
|
|
126
|
+
# to declare what to do with dependents or inverse associations when they
|
|
127
|
+
# are not necessary.
|
|
128
|
+
# @param [Symbol] name
|
|
129
|
+
# @param [<Object>] args
|
|
130
|
+
# @param [{ Symbol => Object }] kwargs
|
|
131
|
+
# @yield Set up the has_one association
|
|
132
|
+
# @yieldreturn [void]
|
|
133
|
+
# @!macro [attach] has_one_readonly
|
|
134
|
+
# @!parse ruby
|
|
135
|
+
# # @note This association is read-only (likely against a database view).
|
|
136
|
+
# has_one $1, -> { readonly }, ${2--1}
|
|
137
|
+
def has_one_readonly(name, *args, **kwargs, &) = has_one(name, *args, **kwargs, &)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
# A concern to determine if a given model has any populated associations
|
|
6
|
+
# with a dependent option set to `:restrict_with_error` or `:restrict_with_exception`.
|
|
7
|
+
#
|
|
8
|
+
# It is used for easy validation in destroy mutations.
|
|
9
|
+
module ChecksBeforeDestruction
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
RESTRICTS_WITH = /\Arestrict_with/
|
|
13
|
+
|
|
14
|
+
# @param [Symbol] name
|
|
15
|
+
def has_existing_association?(name) = association(name).scope.exists?
|
|
16
|
+
|
|
17
|
+
def has_protected_dependents? = protected_dependents.any?
|
|
18
|
+
|
|
19
|
+
def protected_dependents
|
|
20
|
+
self.class.protected_associations.each_with_object([]) do |assoc, names|
|
|
21
|
+
names << assoc.name if has_existing_association?(assoc.name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module ClassMethods
|
|
26
|
+
# @return [<ActiveRecord::Reflection::HasManyReflection>]
|
|
27
|
+
def protected_associations
|
|
28
|
+
@protected_associations ||= reflect_on_all_associations.select do |assoc|
|
|
29
|
+
RESTRICTS_WITH.match? assoc.options[:dependent]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
module IdQuoting
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# Quote the model's primary key if it is persisted and a single string.
|
|
9
|
+
#
|
|
10
|
+
# @return [String, nil]
|
|
11
|
+
def quoted_id
|
|
12
|
+
self.class.connection.quote id if persisted? && (id.kind_of?(String) || id.kind_of?(Integer))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module ClassMethods
|
|
16
|
+
# @return [<String>]
|
|
17
|
+
def quoted_ids
|
|
18
|
+
ids.map { |id| connection.quote id }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
module LazyOrdering
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
module ClassMethods
|
|
9
|
+
# @param [Symbol] column
|
|
10
|
+
# @return [ActiveRecord::Relation]
|
|
11
|
+
def lazily_order(column, raw_direction = :asc)
|
|
12
|
+
base = lazy_order_scope_for column
|
|
13
|
+
|
|
14
|
+
expr = lazy_order_expr_for(column, raw_direction:)
|
|
15
|
+
|
|
16
|
+
base.order(expr)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def lazy_order_column_for(column)
|
|
20
|
+
method_name = :"#{column}_order_column"
|
|
21
|
+
|
|
22
|
+
if respond_to?(method_name)
|
|
23
|
+
public_send(method_name)
|
|
24
|
+
else
|
|
25
|
+
column
|
|
26
|
+
end.then { |expr| arel_attrify(expr) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def lazy_order_expr_for(column, raw_direction: "asc")
|
|
30
|
+
direction = Hotstepper::Support::Types::SimpleSortDirection[raw_direction]
|
|
31
|
+
|
|
32
|
+
method_name = :"#{column}_order_expression"
|
|
33
|
+
|
|
34
|
+
if respond_to?(method_name)
|
|
35
|
+
public_send(method_name, direction:)
|
|
36
|
+
else
|
|
37
|
+
attr = lazy_order_column_for column
|
|
38
|
+
|
|
39
|
+
attr.public_send(direction)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param [Symbol] column
|
|
44
|
+
# @return [ActiveRecord::Relation]
|
|
45
|
+
def lazy_order_scope_for(column)
|
|
46
|
+
scope_name = :"prepare_order_for_#{column}"
|
|
47
|
+
|
|
48
|
+
if respond_to?(scope_name)
|
|
49
|
+
all.public_send(scope_name)
|
|
50
|
+
else
|
|
51
|
+
all
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
module LimitToOne
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
module ClassMethods
|
|
9
|
+
# @raise [ActiveRecord::RecordNotFound]
|
|
10
|
+
# @raise [LimitToOne::TooManyMatches]
|
|
11
|
+
# @return [ApplicationRecord]
|
|
12
|
+
def only_one!
|
|
13
|
+
found_count = limit(2).count
|
|
14
|
+
|
|
15
|
+
raise TooManyMatches, "too many matches for scope" if found_count > 1
|
|
16
|
+
|
|
17
|
+
first!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @raise [LimitToOne::TooManyMatches]
|
|
21
|
+
# @return [ApplicationRecord]
|
|
22
|
+
def only_one_or_initialize
|
|
23
|
+
found_count = limit(2).count
|
|
24
|
+
|
|
25
|
+
raise TooManyMatches, "too many matches for scope" if found_count > 1
|
|
26
|
+
|
|
27
|
+
first_or_initialize
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @api private
|
|
32
|
+
class TooManyMatches < StandardError; end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
module LookupHelpers
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
module ClassMethods
|
|
9
|
+
def define_simple_lookup!(column_name, scope_name: :"lookup_by_#{column_name}")
|
|
10
|
+
scope scope_name, ->(input) { where(column_name => input) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def define_simple_lookups!(*columns)
|
|
14
|
+
columns.each do |column_name|
|
|
15
|
+
define_simple_lookup! column_name
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
# A concern for a materialized view-backed model.
|
|
6
|
+
#
|
|
7
|
+
# @see Hotstepper::Models::View
|
|
8
|
+
# @note It is intended to integrate with the Scenic gem.
|
|
9
|
+
module MaterializedView
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
include Hotstepper::Models::View
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
extend Dry::Core::ClassAttributes
|
|
16
|
+
|
|
17
|
+
defines :refreshes_concurrently, type: Hotstepper::Support::Types::Bool
|
|
18
|
+
|
|
19
|
+
refreshes_concurrently true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module ClassMethods
|
|
23
|
+
# Checks whether the materialized view has been populated with data.
|
|
24
|
+
def populated? = Scenic.database.populated?(table_name)
|
|
25
|
+
|
|
26
|
+
# @return [void]
|
|
27
|
+
def refresh!(concurrently: refreshes_concurrently, cascade: false)
|
|
28
|
+
Scenic.database.refresh_materialized_view(table_name, concurrently:, cascade:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @api private
|
|
32
|
+
# @return [void]
|
|
33
|
+
def refreshes_concurrently!
|
|
34
|
+
refreshes_concurrently true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
# A concern that is included on `ActiveRecord::Base` models.
|
|
6
|
+
module Model
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
include AssociationHelpers
|
|
10
|
+
include ChecksBeforeDestruction
|
|
11
|
+
include IdQuoting
|
|
12
|
+
include LazyOrdering
|
|
13
|
+
include LimitToOne
|
|
14
|
+
include LookupHelpers
|
|
15
|
+
include Querying
|
|
16
|
+
include WhereMatches
|
|
17
|
+
include Hotstepper::Common::CallsOperation
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
extend Hotstepper::ArelExt::Helpers
|
|
21
|
+
extend Hotstepper::Monadic::Decorators
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module ClassMethods
|
|
25
|
+
# @api private
|
|
26
|
+
# @note Only used for tests
|
|
27
|
+
def default_factory
|
|
28
|
+
# :nocov:
|
|
29
|
+
model_name.i18n_key
|
|
30
|
+
# :nocov:
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [void]
|
|
34
|
+
def with_timestamp_scopes!
|
|
35
|
+
include TimestampScopes
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
# Generic querying helpers that don't fit in elsewhere.
|
|
6
|
+
module Querying
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
# Sometimes we need to get a count on something that has aggregations,
|
|
11
|
+
# `DISTINCT ON`, etc. This should handle most cases.
|
|
12
|
+
# @param [Boolean] strip_order
|
|
13
|
+
# @return [Integer]
|
|
14
|
+
def count_from_subquery(strip_order: false)
|
|
15
|
+
subquery = all
|
|
16
|
+
|
|
17
|
+
subquery = subquery.reorder(nil) if strip_order
|
|
18
|
+
|
|
19
|
+
unscoped.from(subquery).count
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param [<ApplicationRecord>] records
|
|
23
|
+
# @return [ActiveRecord::Relation<ApplicationRecord>]
|
|
24
|
+
def limited_to(*records)
|
|
25
|
+
flattened = records.flatten
|
|
26
|
+
|
|
27
|
+
return all if flattened.blank?
|
|
28
|
+
|
|
29
|
+
id = flattened.filter_map do |record|
|
|
30
|
+
case record
|
|
31
|
+
when self then record.id
|
|
32
|
+
when ::Hotstepper::Support::Types::UUID, Hotstepper::Support::Types::Integer then record
|
|
33
|
+
end
|
|
34
|
+
end.uniq
|
|
35
|
+
|
|
36
|
+
id.blank? ? none : where(id:)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param [Integer, nil] number of records to return, or nil to return a single record
|
|
40
|
+
# @return [ApplicationRecord, ActiveRecord::Relation<ApplicationRecord>, nil]
|
|
41
|
+
def sample(num = nil)
|
|
42
|
+
randomized = reorder(Arel.sql("RANDOM()"))
|
|
43
|
+
|
|
44
|
+
if num.is_a?(Integer) && num >= 1
|
|
45
|
+
randomized.limit(num)
|
|
46
|
+
else
|
|
47
|
+
randomized.first
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
alias scoped_sample sample
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
# @note Not automatically included in {Hotstepper::Models::Model},
|
|
6
|
+
# since not all models will have timestamps.
|
|
7
|
+
module TimestampScopes
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
scope :in_recent_order, -> { order(created_at: :desc) }
|
|
12
|
+
scope :in_oldest_order, -> { order(created_at: :asc) }
|
|
13
|
+
scope :most_recently_updated, -> { order(updated_at: :desc) }
|
|
14
|
+
scope :least_recently_updated, -> { order(updated_at: :asc) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module ClassMethods
|
|
18
|
+
# @return [ApplicationRecord, nil]
|
|
19
|
+
def latest = in_recent_order.first
|
|
20
|
+
|
|
21
|
+
# @return [ApplicationRecord, nil]
|
|
22
|
+
def oldest = in_oldest_order.first
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hotstepper
|
|
4
|
+
module Models
|
|
5
|
+
# A concern for building `LIKE` / `ILIKE` queries.
|
|
6
|
+
module WhereMatches
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def where_matches(_wildcard: false, _escape: true, _case_sensitive: false, _or: false, **pairs)
|
|
11
|
+
counter = 0
|
|
12
|
+
|
|
13
|
+
pairs.reduce(all) do |scope, (column, value)|
|
|
14
|
+
counter += 1
|
|
15
|
+
|
|
16
|
+
next scope if value.blank?
|
|
17
|
+
|
|
18
|
+
conditions = arel_or_expressions value do |actual_value|
|
|
19
|
+
needle = wrap_like_value_in_wildcard(actual_value, _wildcard, escape: _escape)
|
|
20
|
+
|
|
21
|
+
arel_table[column].matches(needle, nil, _case_sensitive)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if _or && counter > 1
|
|
25
|
+
scope.or(scope.unscoped.where(conditions))
|
|
26
|
+
else
|
|
27
|
+
scope.where conditions
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def where_like(_wildcard: :both, **pairs)
|
|
33
|
+
where_matches(**pairs, _wildcard:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def where_contains(**pairs)
|
|
37
|
+
where_matches _wildcard: :both, **pairs
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def where_begins_like(**pairs)
|
|
41
|
+
where_matches _wildcard: :end, **pairs
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def where_ends_like(**pairs)
|
|
45
|
+
where_matches _wildcard: :start, **pairs
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def wrap_like_value_in_wildcard(value, wildcard_type = :both, escape: true)
|
|
51
|
+
needle = escape ? value.to_s.gsub(?%, "\\%").gsub(?_, "\\_") : value
|
|
52
|
+
|
|
53
|
+
case wildcard_type
|
|
54
|
+
when :start
|
|
55
|
+
"%#{needle}"
|
|
56
|
+
when :end
|
|
57
|
+
"#{needle}%"
|
|
58
|
+
when :both
|
|
59
|
+
"%#{needle}%"
|
|
60
|
+
else
|
|
61
|
+
needle.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|