kirei 0.2.1 → 0.4.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 +4 -4
- data/README.md +74 -25
- data/bin/kirei +1 -1
- data/kirei.gemspec +5 -3
- data/lib/cli/commands/new_app/base_directories.rb +1 -1
- data/lib/cli/commands/new_app/execute.rb +3 -3
- data/lib/cli/commands/new_app/files/app.rb +16 -3
- data/lib/cli/commands/new_app/files/config_ru.rb +1 -1
- data/lib/cli/commands/new_app/files/db_rake.rb +50 -2
- data/lib/cli/commands/new_app/files/irbrc.rb +1 -1
- data/lib/cli/commands/new_app/files/rakefile.rb +1 -1
- data/lib/cli/commands/new_app/files/routes.rb +49 -12
- data/lib/cli/commands/new_app/files/sorbet_config.rb +1 -1
- data/lib/cli/commands/start.rb +1 -1
- data/lib/kirei/app.rb +76 -56
- data/lib/kirei/config.rb +4 -1
- data/lib/kirei/controller.rb +44 -0
- data/lib/kirei/errors/json_api_error.rb +25 -0
- data/lib/kirei/errors/json_api_error_source.rb +12 -0
- data/lib/kirei/logging/level.rb +33 -0
- data/lib/kirei/logging/logger.rb +198 -0
- data/lib/kirei/logging/metric.rb +40 -0
- data/lib/kirei/model/base_class_interface.rb +55 -0
- data/lib/kirei/{base_model.rb → model/class_methods.rb} +42 -108
- data/lib/kirei/model/human_id_generator.rb +40 -0
- data/lib/kirei/model.rb +52 -0
- data/lib/kirei/routing/base.rb +187 -0
- data/lib/kirei/routing/nilable_hooks_type.rb +10 -0
- data/lib/kirei/{middleware.rb → routing/rack_env_type.rb} +1 -10
- data/lib/kirei/routing/rack_response_type.rb +15 -0
- data/lib/kirei/routing/route.rb +13 -0
- data/lib/kirei/routing/router.rb +56 -0
- data/lib/kirei/routing/verb.rb +37 -0
- data/lib/kirei/services/result.rb +53 -0
- data/lib/kirei/services/runner.rb +47 -0
- data/lib/kirei/version.rb +1 -1
- data/lib/kirei.rb +31 -3
- data/sorbet/rbi/shims/base_model.rbi +1 -1
- data/sorbet/rbi/shims/ruby.rbi +15 -0
- metadata +55 -14
- data/lib/boot.rb +0 -23
- data/lib/kirei/app_base.rb +0 -72
- data/lib/kirei/base_controller.rb +0 -16
- data/lib/kirei/logger.rb +0 -196
- data/lib/kirei/router.rb +0 -61
@@ -0,0 +1,44 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Kirei
|
5
|
+
class Controller < Routing::Base
|
6
|
+
class << self
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
sig { returns(Routing::NilableHooksType) }
|
10
|
+
attr_reader :before_hooks, :after_hooks
|
11
|
+
end
|
12
|
+
|
13
|
+
extend T::Sig
|
14
|
+
|
15
|
+
#
|
16
|
+
# Statements to be executed before every action.
|
17
|
+
#
|
18
|
+
# In development mode, Rack Reloader might reload this file causing
|
19
|
+
# the before hooks to be executed multiple times.
|
20
|
+
#
|
21
|
+
sig do
|
22
|
+
params(
|
23
|
+
block: T.nilable(T.proc.void),
|
24
|
+
).void
|
25
|
+
end
|
26
|
+
def self.before(&block)
|
27
|
+
@before_hooks ||= T.let(Set.new, Routing::NilableHooksType)
|
28
|
+
@before_hooks.add(block) if block
|
29
|
+
end
|
30
|
+
|
31
|
+
#
|
32
|
+
# Statements to be executed after every action.
|
33
|
+
#
|
34
|
+
sig do
|
35
|
+
params(
|
36
|
+
block: T.nilable(T.proc.void),
|
37
|
+
).void
|
38
|
+
end
|
39
|
+
def self.after(&block)
|
40
|
+
@after_hooks ||= T.let(Set.new, Routing::NilableHooksType)
|
41
|
+
@after_hooks.add(block) if block
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Kirei
|
5
|
+
module Errors
|
6
|
+
#
|
7
|
+
# https://jsonapi.org/format/#errors
|
8
|
+
# Error objects MUST be returned as an array keyed by errors in the top level of a JSON:API document.
|
9
|
+
#
|
10
|
+
class JsonApiError < T::Struct
|
11
|
+
#
|
12
|
+
# An application-specific error code, expressed as a string value.
|
13
|
+
#
|
14
|
+
const :code, String
|
15
|
+
|
16
|
+
#
|
17
|
+
# A human-readable explanation specific to this occurrence of the problem.
|
18
|
+
# Like title, this field's value can be localized.
|
19
|
+
#
|
20
|
+
const :detail, T.nilable(String)
|
21
|
+
|
22
|
+
const :source, T.nilable(JsonApiErrorSource)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Kirei
|
5
|
+
module Logging
|
6
|
+
class Level < T::Enum
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
enums do
|
10
|
+
UNKNOWN = new(5) # An unknown message that should always be logged.
|
11
|
+
FATAL = new(4) # An unhandleable error that results in a program crash.
|
12
|
+
ERROR = new(3) # A handleable error condition.
|
13
|
+
WARN = new(2) # A warning.
|
14
|
+
INFO = new(1) # Generic (useful) information about system operation.
|
15
|
+
DEBUG = new(0) # Low-level information for developers.
|
16
|
+
end
|
17
|
+
|
18
|
+
sig { returns(String) }
|
19
|
+
def to_human
|
20
|
+
case self
|
21
|
+
when UNKNOWN then "UNKNOWN"
|
22
|
+
when FATAL then "FATAL"
|
23
|
+
when ERROR then "ERROR"
|
24
|
+
when WARN then "WARN"
|
25
|
+
when INFO then "INFO"
|
26
|
+
when DEBUG then "DEBUG"
|
27
|
+
else
|
28
|
+
T.absurd(self)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,198 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Kirei
|
5
|
+
# rubocop:disable Metrics
|
6
|
+
|
7
|
+
#
|
8
|
+
# Example Usage:
|
9
|
+
#
|
10
|
+
# Kirei::Logging::Logger.call(
|
11
|
+
# level: Kirei::Logging::Level::INFO,
|
12
|
+
# label: "Request started",
|
13
|
+
# meta: {
|
14
|
+
# key: "value",
|
15
|
+
# },
|
16
|
+
# )
|
17
|
+
#
|
18
|
+
# You can define a custom log transformer to transform the logline:
|
19
|
+
#
|
20
|
+
# Kirei::App.config.log_transformer = Proc.new { _1 }
|
21
|
+
#
|
22
|
+
# By default, "meta" is flattened, and sensitive values are masked using sane defaults that you
|
23
|
+
# can finetune via `Kirei::App.config.sensitive_keys`.
|
24
|
+
#
|
25
|
+
# You can also build on top of the provided log transformer:
|
26
|
+
#
|
27
|
+
# Kirei::App.config.log_transformer = Proc.new do |meta|
|
28
|
+
# flattened_meta = Kirei::Logging::Logger.flatten_hash_and_mask_sensitive_values(meta)
|
29
|
+
# # Do something with the flattened meta
|
30
|
+
# flattened_meta.map { _1.to_json }
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# NOTE:
|
34
|
+
# * The log transformer must return an array of strings to allow emitting multiple lines per log event.
|
35
|
+
# * Whenever possible, key names follow OpenTelemetry Semantic Conventions, https://opentelemetry.io/docs/concepts/semantic-conventions/
|
36
|
+
#
|
37
|
+
module Logging
|
38
|
+
class Logger
|
39
|
+
extend T::Sig
|
40
|
+
|
41
|
+
FILTERED = "[FILTERED]"
|
42
|
+
|
43
|
+
@instance = T.let(nil, T.nilable(Kirei::Logging::Logger))
|
44
|
+
|
45
|
+
sig { void }
|
46
|
+
def initialize
|
47
|
+
super
|
48
|
+
@queue = T.let(Thread::Queue.new, Thread::Queue)
|
49
|
+
@thread = T.let(start_logging_thread, Thread)
|
50
|
+
end
|
51
|
+
|
52
|
+
sig { returns(Kirei::Logging::Logger) }
|
53
|
+
def self.instance
|
54
|
+
@instance ||= new
|
55
|
+
end
|
56
|
+
|
57
|
+
sig { returns(::Logger) }
|
58
|
+
def self.logger
|
59
|
+
return @logger unless @logger.nil?
|
60
|
+
|
61
|
+
@logger = T.let(nil, T.nilable(::Logger))
|
62
|
+
@logger ||= ::Logger.new($stdout)
|
63
|
+
|
64
|
+
# we want the logline to be parseable to JSON
|
65
|
+
@logger.formatter = proc do |_severity, _datetime, _progname, msg|
|
66
|
+
"#{msg}\n"
|
67
|
+
end
|
68
|
+
|
69
|
+
@logger
|
70
|
+
end
|
71
|
+
|
72
|
+
sig do
|
73
|
+
params(
|
74
|
+
level: Logging::Level,
|
75
|
+
label: String,
|
76
|
+
meta: T::Hash[String, T.untyped],
|
77
|
+
).void
|
78
|
+
end
|
79
|
+
def self.call(level:, label:, meta: {})
|
80
|
+
return if ENV["NO_LOGS"] == "true"
|
81
|
+
return if level.serialize < App.config.log_level.serialize
|
82
|
+
|
83
|
+
# must extract data from current thread before passing this down to the logging thread
|
84
|
+
meta["enduser.id"] ||= Thread.current[:enduser_id] # OpenTelemetry::SemanticConventions::Trace::ENDUSER_ID
|
85
|
+
meta["service.instance.id"] ||= Thread.current[:request_id] # OpenTelemetry::SemanticConventions::Resource::SERVICE_INSTANCE_ID
|
86
|
+
|
87
|
+
instance.call(level: level, label: label, meta: meta)
|
88
|
+
end
|
89
|
+
|
90
|
+
sig do
|
91
|
+
params(
|
92
|
+
level: Logging::Level,
|
93
|
+
label: String,
|
94
|
+
meta: T::Hash[String, T.untyped],
|
95
|
+
).void
|
96
|
+
end
|
97
|
+
def call(level:, label:, meta: {})
|
98
|
+
Kirei::App.config.log_default_metadata.each_pair do |key, value|
|
99
|
+
meta[key] ||= value
|
100
|
+
end
|
101
|
+
|
102
|
+
meta["service.name"] ||= Kirei::App.config.app_name # OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME
|
103
|
+
meta["service.version"] = Kirei::App.version # OpenTelemetry::SemanticConventions::Resource::SERVICE_VERSION
|
104
|
+
meta["timestamp"] ||= Time.now.utc.iso8601
|
105
|
+
meta["level"] ||= level.to_human
|
106
|
+
meta["label"] ||= label
|
107
|
+
|
108
|
+
@queue << meta
|
109
|
+
end
|
110
|
+
|
111
|
+
sig { returns(Thread) }
|
112
|
+
def start_logging_thread
|
113
|
+
Thread.new do
|
114
|
+
Kernel.loop do
|
115
|
+
log_data = T.let(@queue.pop, T::Hash[Symbol, T.untyped])
|
116
|
+
log_transformer = App.config.log_transformer
|
117
|
+
|
118
|
+
loglines = if log_transformer
|
119
|
+
log_transformer.call(log_data)
|
120
|
+
else
|
121
|
+
[Oj.dump(
|
122
|
+
Kirei::Logging::Logger.flatten_hash_and_mask_sensitive_values(log_data),
|
123
|
+
Kirei::OJ_OPTIONS,
|
124
|
+
)]
|
125
|
+
end
|
126
|
+
|
127
|
+
loglines.each do |logline|
|
128
|
+
logline = "\n#{logline}\n" if Kirei::App.environment == "development"
|
129
|
+
Kirei::Logging::Logger.logger.unknown(logline)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
# rubocop:disable Naming/MethodParameterName
|
136
|
+
sig do
|
137
|
+
params(
|
138
|
+
k: String,
|
139
|
+
v: String,
|
140
|
+
).returns(String)
|
141
|
+
end
|
142
|
+
def self.mask(k, v)
|
143
|
+
App.config.sensitive_keys.any? { k.match?(_1) } ? FILTERED : v
|
144
|
+
end
|
145
|
+
# rubocop:enable Naming/MethodParameterName
|
146
|
+
|
147
|
+
sig do
|
148
|
+
params(
|
149
|
+
hash: T::Hash[T.any(Symbol, String), T.untyped],
|
150
|
+
prefix: String,
|
151
|
+
).returns(T::Hash[String, T.untyped])
|
152
|
+
end
|
153
|
+
def self.flatten_hash_and_mask_sensitive_values(hash, prefix = "")
|
154
|
+
result = T.let({}, T::Hash[String, T.untyped])
|
155
|
+
Kirei::Helpers.deep_stringify_keys!(hash)
|
156
|
+
hash = T.cast(hash, T::Hash[String, T.untyped])
|
157
|
+
|
158
|
+
hash.each do |key, value|
|
159
|
+
new_prefix = Kirei::Helpers.blank?(prefix) ? key : "#{prefix}.#{key}"
|
160
|
+
|
161
|
+
case value
|
162
|
+
when Hash
|
163
|
+
# Some libraries have a custom Hash class that inhert from Hash, but act differently, e.g. OmniAuth::AuthHash.
|
164
|
+
# This results in `transform_keys` being available but without any effect.
|
165
|
+
value = value.to_h if value.class != Hash
|
166
|
+
result.merge!(flatten_hash_and_mask_sensitive_values(value.transform_keys(&:to_s), new_prefix))
|
167
|
+
when Array
|
168
|
+
value.each_with_index do |element, index|
|
169
|
+
if element.is_a?(Hash) || element.is_a?(Array)
|
170
|
+
result.merge!(flatten_hash_and_mask_sensitive_values({ index => element }, new_prefix))
|
171
|
+
else
|
172
|
+
result["#{new_prefix}.#{index}"] = element.is_a?(String) ? mask(key, element) : element
|
173
|
+
end
|
174
|
+
end
|
175
|
+
when String then result[new_prefix] = mask(key, value)
|
176
|
+
when Numeric, FalseClass, TrueClass, NilClass then result[new_prefix] = value
|
177
|
+
else
|
178
|
+
if value.respond_to?(:serialize)
|
179
|
+
serialized_value = value.serialize
|
180
|
+
if serialized_value.is_a?(Hash)
|
181
|
+
result.merge!(
|
182
|
+
flatten_hash_and_mask_sensitive_values(serialized_value.transform_keys(&:to_s), new_prefix),
|
183
|
+
)
|
184
|
+
else
|
185
|
+
result[new_prefix] = serialized_value&.to_s
|
186
|
+
end
|
187
|
+
else
|
188
|
+
result[new_prefix] = value&.to_s
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
result
|
194
|
+
end
|
195
|
+
end
|
196
|
+
# rubocop:enable Metrics
|
197
|
+
end
|
198
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Kirei
|
5
|
+
module Logging
|
6
|
+
class Metric
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
sig do
|
10
|
+
params(
|
11
|
+
metric_name: String,
|
12
|
+
value: Integer,
|
13
|
+
tags: T::Hash[String, T.untyped],
|
14
|
+
).void
|
15
|
+
end
|
16
|
+
def self.call(metric_name, value = 1, tags: {})
|
17
|
+
return if ENV["NO_METRICS"] == "true"
|
18
|
+
|
19
|
+
inject_defaults(tags)
|
20
|
+
|
21
|
+
# Do not `compact_blank` tags, since one might want to track empty strings/"false"/NULLs.
|
22
|
+
# NOT having any tag doesn't tell the user if the tag was empty or not set at all.
|
23
|
+
StatsD.increment(metric_name, value, tags: tags)
|
24
|
+
end
|
25
|
+
|
26
|
+
sig { params(tags: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
|
27
|
+
def self.inject_defaults(tags)
|
28
|
+
App.config.metric_default_tags.each_pair do |key, default_value|
|
29
|
+
tags[key] ||= default_value
|
30
|
+
end
|
31
|
+
|
32
|
+
tags["enduser.id"] ||= Thread.current[:enduser_id]
|
33
|
+
tags["service.name"] ||= Kirei::App.config.app_name # OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME
|
34
|
+
tags["service.version"] = Kirei::App.version # OpenTelemetry::SemanticConventions::Resource::SERVICE_VERSION
|
35
|
+
|
36
|
+
tags
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# rubocop:disable Style/EmptyMethod
|
5
|
+
|
6
|
+
module Kirei
|
7
|
+
module Model
|
8
|
+
module BaseClassInterface
|
9
|
+
extend T::Sig
|
10
|
+
extend T::Helpers
|
11
|
+
interface!
|
12
|
+
|
13
|
+
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
14
|
+
def find_by(hash); end
|
15
|
+
|
16
|
+
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
17
|
+
def where(hash); end
|
18
|
+
|
19
|
+
sig { abstract.returns(T.untyped) }
|
20
|
+
def all; end
|
21
|
+
|
22
|
+
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
23
|
+
def create(hash); end
|
24
|
+
|
25
|
+
sig { abstract.params(attributes: T.untyped).void }
|
26
|
+
def wrap_jsonb_non_primivitives!(attributes); end
|
27
|
+
|
28
|
+
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
29
|
+
def resolve(hash); end
|
30
|
+
|
31
|
+
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
32
|
+
def resolve_first(hash); end
|
33
|
+
|
34
|
+
sig { abstract.returns(T.untyped) }
|
35
|
+
def table_name; end
|
36
|
+
|
37
|
+
sig { abstract.returns(T.untyped) }
|
38
|
+
def query; end
|
39
|
+
|
40
|
+
sig { abstract.returns(T.untyped) }
|
41
|
+
def db; end
|
42
|
+
|
43
|
+
sig { abstract.returns(T.untyped) }
|
44
|
+
def human_id_length; end
|
45
|
+
|
46
|
+
sig { abstract.returns(T.untyped) }
|
47
|
+
def human_id_prefix; end
|
48
|
+
|
49
|
+
sig { abstract.returns(T.untyped) }
|
50
|
+
def generate_human_id; end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# rubocop:enable Style/EmptyMethod
|
@@ -2,120 +2,36 @@
|
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
4
|
module Kirei
|
5
|
-
module
|
6
|
-
extend T::Sig
|
7
|
-
extend T::Helpers
|
8
|
-
|
9
|
-
sig { returns(BaseClassInterface) }
|
10
|
-
def class; super; end # rubocop:disable all
|
11
|
-
|
12
|
-
# An update keeps the original object intact, and returns a new object with the updated values.
|
13
|
-
sig do
|
14
|
-
params(
|
15
|
-
hash: T::Hash[Symbol, T.untyped],
|
16
|
-
).returns(T.self_type)
|
17
|
-
end
|
18
|
-
def update(hash)
|
19
|
-
hash[:updated_at] = Time.now.utc if respond_to?(:updated_at) && hash[:updated_at].nil?
|
20
|
-
self.class.wrap_jsonb_non_primivitives!(hash)
|
21
|
-
self.class.db.where({ id: id }).update(hash)
|
22
|
-
self.class.find_by({ id: id })
|
23
|
-
end
|
24
|
-
|
25
|
-
# Delete keeps the original object intact. Returns true if the record was deleted.
|
26
|
-
# Calling delete multiple times will return false after the first (successful) call.
|
27
|
-
sig { returns(T::Boolean) }
|
28
|
-
def delete
|
29
|
-
count = self.class.db.where({ id: id }).delete
|
30
|
-
count == 1
|
31
|
-
end
|
32
|
-
|
33
|
-
# warning: this is not concurrency-safe
|
34
|
-
# save keeps the original object intact, and returns a new object with the updated values.
|
35
|
-
sig { returns(T.self_type) }
|
36
|
-
def save
|
37
|
-
previous_record = self.class.find_by({ id: id })
|
38
|
-
|
39
|
-
hash = serialize
|
40
|
-
Helpers.deep_symbolize_keys!(hash)
|
41
|
-
hash = T.cast(hash, T::Hash[Symbol, T.untyped])
|
42
|
-
|
43
|
-
if previous_record.nil?
|
44
|
-
self.class.create(hash)
|
45
|
-
else
|
46
|
-
update(hash)
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
module BaseClassInterface
|
51
|
-
extend T::Sig
|
52
|
-
extend T::Helpers
|
53
|
-
interface!
|
54
|
-
|
55
|
-
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
56
|
-
def find_by(hash)
|
57
|
-
end
|
58
|
-
|
59
|
-
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
60
|
-
def where(hash)
|
61
|
-
end
|
62
|
-
|
63
|
-
sig { abstract.returns(T.untyped) }
|
64
|
-
def all
|
65
|
-
end
|
66
|
-
|
67
|
-
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
68
|
-
def create(hash)
|
69
|
-
end
|
70
|
-
|
71
|
-
sig { abstract.params(attributes: T.untyped).void }
|
72
|
-
def wrap_jsonb_non_primivitives!(attributes)
|
73
|
-
end
|
74
|
-
|
75
|
-
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
76
|
-
def resolve(hash)
|
77
|
-
end
|
78
|
-
|
79
|
-
sig { abstract.params(hash: T.untyped).returns(T.untyped) }
|
80
|
-
def resolve_first(hash)
|
81
|
-
end
|
82
|
-
|
83
|
-
sig { abstract.returns(T.untyped) }
|
84
|
-
def table_name
|
85
|
-
end
|
86
|
-
|
87
|
-
sig { abstract.returns(T.untyped) }
|
88
|
-
def db
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
5
|
+
module Model
|
92
6
|
module ClassMethods
|
93
7
|
extend T::Sig
|
94
8
|
extend T::Generic
|
95
9
|
|
96
10
|
# the attached class is the class that extends this module
|
97
|
-
# e.g. "User"
|
98
|
-
# extend T::Generic
|
99
|
-
# has_attached_class!
|
11
|
+
# e.g. "User", "Airport", ..
|
100
12
|
has_attached_class!
|
101
13
|
|
102
|
-
include BaseClassInterface
|
14
|
+
include Kirei::Model::BaseClassInterface
|
103
15
|
|
104
16
|
# defaults to a pluralized, underscored version of the class name
|
105
17
|
sig { override.returns(String) }
|
106
18
|
def table_name
|
107
|
-
@table_name ||= T.let(
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
)
|
19
|
+
@table_name ||= T.let("#{model_name}s", T.nilable(String))
|
20
|
+
end
|
21
|
+
|
22
|
+
sig { returns(String) }
|
23
|
+
def model_name
|
24
|
+
Kirei::Helpers.underscore(T.must(name.split("::").last))
|
114
25
|
end
|
115
26
|
|
116
27
|
sig { override.returns(Sequel::Dataset) }
|
28
|
+
def query
|
29
|
+
db[table_name.to_sym]
|
30
|
+
end
|
31
|
+
|
32
|
+
sig { override.returns(Sequel::Database) }
|
117
33
|
def db
|
118
|
-
|
34
|
+
App.raw_db_connection
|
119
35
|
end
|
120
36
|
|
121
37
|
sig do
|
@@ -124,12 +40,12 @@ module Kirei
|
|
124
40
|
).returns(T::Array[T.attached_class])
|
125
41
|
end
|
126
42
|
def where(hash)
|
127
|
-
resolve(
|
43
|
+
resolve(query.where(hash))
|
128
44
|
end
|
129
45
|
|
130
46
|
sig { override.returns(T::Array[T.attached_class]) }
|
131
47
|
def all
|
132
|
-
resolve(
|
48
|
+
resolve(query.all)
|
133
49
|
end
|
134
50
|
|
135
51
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
@@ -156,7 +72,7 @@ module Kirei
|
|
156
72
|
all_attributes["updated_at"] = Time.now.utc
|
157
73
|
end
|
158
74
|
|
159
|
-
pkey = T.let(
|
75
|
+
pkey = T.let(query.insert(all_attributes), String)
|
160
76
|
|
161
77
|
T.must(find_by({ id: pkey }))
|
162
78
|
end
|
@@ -166,7 +82,7 @@ module Kirei
|
|
166
82
|
def wrap_jsonb_non_primivitives!(attributes)
|
167
83
|
# setting `@raw_db_connection.wrap_json_primitives = true`
|
168
84
|
# only works on JSON primitives, but not on blank hashes/arrays
|
169
|
-
return unless
|
85
|
+
return unless App.config.db_extensions.include?(:pg_json)
|
170
86
|
|
171
87
|
attributes.each_pair do |key, value|
|
172
88
|
next unless value.is_a?(Hash) || value.is_a?(Array)
|
@@ -181,7 +97,7 @@ module Kirei
|
|
181
97
|
).returns(T.nilable(T.attached_class))
|
182
98
|
end
|
183
99
|
def find_by(hash)
|
184
|
-
resolve_first(
|
100
|
+
resolve_first(query.where(hash))
|
185
101
|
end
|
186
102
|
|
187
103
|
# Extra or unknown properties present in the Hash do not raise exceptions at
|
@@ -196,7 +112,7 @@ module Kirei
|
|
196
112
|
).returns(T::Array[T.attached_class])
|
197
113
|
end
|
198
114
|
def resolve(query, strict = nil)
|
199
|
-
strict_loading = strict.nil? ?
|
115
|
+
strict_loading = strict.nil? ? App.config.db_strict_type_resolving : strict
|
200
116
|
|
201
117
|
query.map do |row|
|
202
118
|
row = T.cast(row, T::Hash[Symbol, T.untyped])
|
@@ -212,12 +128,30 @@ module Kirei
|
|
212
128
|
).returns(T.nilable(T.attached_class))
|
213
129
|
end
|
214
130
|
def resolve_first(query, strict = nil)
|
215
|
-
strict_loading = strict.nil? ?
|
131
|
+
strict_loading = strict.nil? ? App.config.db_strict_type_resolving : strict
|
216
132
|
|
217
133
|
resolve(query.limit(1), strict_loading).first
|
218
134
|
end
|
219
|
-
end
|
220
135
|
|
221
|
-
|
136
|
+
# defaults to 6
|
137
|
+
sig { override.returns(Integer) }
|
138
|
+
def human_id_length = 6
|
139
|
+
|
140
|
+
# defaults to "model_name" (table_name without the trailing "s")
|
141
|
+
sig { override.returns(String) }
|
142
|
+
def human_id_prefix = model_name
|
143
|
+
|
144
|
+
#
|
145
|
+
# Generates a human-readable ID for the record.
|
146
|
+
# The ID is prefixed with the table name and an underscore.
|
147
|
+
#
|
148
|
+
sig { override.returns(String) }
|
149
|
+
def generate_human_id
|
150
|
+
Kirei::Model::HumanIdGenerator.call(
|
151
|
+
length: human_id_length,
|
152
|
+
prefix: human_id_prefix,
|
153
|
+
)
|
154
|
+
end
|
155
|
+
end
|
222
156
|
end
|
223
157
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Kirei
|
5
|
+
module Model
|
6
|
+
class HumanIdGenerator
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
# Removed ambiguous characters 0, 1, O, I, l, 5, S
|
10
|
+
ALLOWED_CHARS = "2346789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrtuvwxyz"
|
11
|
+
private_constant :ALLOWED_CHARS
|
12
|
+
|
13
|
+
ALLOWED_CHARS_COUNT = T.let(ALLOWED_CHARS.size, Integer)
|
14
|
+
private_constant :ALLOWED_CHARS_COUNT
|
15
|
+
|
16
|
+
sig do
|
17
|
+
params(
|
18
|
+
length: Integer,
|
19
|
+
prefix: String,
|
20
|
+
).returns(String)
|
21
|
+
end
|
22
|
+
def self.call(length:, prefix:)
|
23
|
+
"#{prefix}_#{random_id(length)}"
|
24
|
+
end
|
25
|
+
|
26
|
+
sig { params(key_length: Integer).returns(String) }
|
27
|
+
private_class_method def self.random_id(key_length)
|
28
|
+
random_chars = Array.new(key_length)
|
29
|
+
|
30
|
+
key_length.times do |idx|
|
31
|
+
random_char_idx = SecureRandom.random_number(ALLOWED_CHARS_COUNT)
|
32
|
+
random_char = T.must(ALLOWED_CHARS[random_char_idx])
|
33
|
+
random_chars[idx] = random_char
|
34
|
+
end
|
35
|
+
|
36
|
+
random_chars.join
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|