zero-rails-adapter 0.2.0 → 0.3.1
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/CHANGELOG.md +150 -0
- data/README.md +178 -22
- data/app/controllers/zero_rails_adapter/mutations_controller.rb +5 -1
- data/examples/nextjs/README.md +4 -2
- data/lib/generators/zero_rails_adapter/install/templates/initializer.rb +28 -9
- data/lib/generators/zero_rails_adapter/mutator/templates/mutator.rb +4 -1
- data/lib/generators/zero_rails_adapter/publication/publication_generator.rb +21 -0
- data/lib/zero_rails_adapter/configuration.rb +13 -17
- data/lib/zero_rails_adapter/crud/dispatcher.rb +12 -6
- data/lib/zero_rails_adapter/errors.rb +2 -0
- data/lib/zero_rails_adapter/mutator.rb +3 -1
- data/lib/zero_rails_adapter/postgresql/publication_generator.rb +36 -0
- data/lib/zero_rails_adapter/processor.rb +35 -7
- data/lib/zero_rails_adapter/published_schema.rb +143 -0
- data/lib/zero_rails_adapter/relationship.rb +129 -0
- data/lib/zero_rails_adapter/type_script/generator.rb +110 -52
- data/lib/zero_rails_adapter/version.rb +1 -1
- data/lib/zero_rails_adapter.rb +3 -0
- metadata +7 -2
|
@@ -68,7 +68,8 @@ module ZeroRailsAdapter
|
|
|
68
68
|
def update(model, attributes)
|
|
69
69
|
record = find_record!(model, attributes)
|
|
70
70
|
authorize!(:update, record, attributes)
|
|
71
|
-
|
|
71
|
+
immutable_keys = zero_keys(model) + Array(model.primary_key).compact.map(&:to_s)
|
|
72
|
+
changes = writable(model, :update, attributes).except(*immutable_keys)
|
|
72
73
|
record.update!(changes)
|
|
73
74
|
nil
|
|
74
75
|
end
|
|
@@ -81,21 +82,26 @@ module ZeroRailsAdapter
|
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
def find_record!(model, attributes)
|
|
84
|
-
keys =
|
|
85
|
+
keys = zero_keys(model)
|
|
85
86
|
values = attributes.slice(*keys)
|
|
86
87
|
missing = keys - values.keys
|
|
87
88
|
if missing.any?
|
|
88
89
|
raise ValidationError.new(
|
|
89
|
-
"Missing
|
|
90
|
-
details: {"
|
|
90
|
+
"Missing Zero key attributes: #{missing.join(', ')}",
|
|
91
|
+
details: {"zeroKey" => missing}
|
|
91
92
|
)
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
model.find_by!(values)
|
|
95
96
|
end
|
|
96
97
|
|
|
97
|
-
def
|
|
98
|
-
Array(
|
|
98
|
+
def zero_keys(model)
|
|
99
|
+
keys = Array(
|
|
100
|
+
ZeroRailsAdapter.configuration.zero_key.call(model)
|
|
101
|
+
).compact.map(&:to_s)
|
|
102
|
+
return keys if keys.any?
|
|
103
|
+
|
|
104
|
+
raise ValidationError, "#{model.name} must define at least one Zero key"
|
|
99
105
|
end
|
|
100
106
|
|
|
101
107
|
def authorize!(action, target, attributes)
|
|
@@ -17,6 +17,8 @@ module ZeroRailsAdapter
|
|
|
17
17
|
class UnauthorizedError < Error; end
|
|
18
18
|
class UnknownMutatorError < ApplicationError; end
|
|
19
19
|
class UnsupportedColumnTypeError < Error; end
|
|
20
|
+
class UnsafePublicationError < Error; end
|
|
21
|
+
class InvalidRelationshipError < Error; end
|
|
20
22
|
|
|
21
23
|
class ProtocolError < Error
|
|
22
24
|
attr_reader :mutation_ids
|
|
@@ -61,7 +61,9 @@ module ZeroRailsAdapter
|
|
|
61
61
|
|
|
62
62
|
def authorize!
|
|
63
63
|
callback = self.class.authorization_callback
|
|
64
|
-
|
|
64
|
+
unless callback
|
|
65
|
+
raise ForbiddenError, "Mutator authorization is not configured"
|
|
66
|
+
end
|
|
65
67
|
return true if instance_exec(context, &callback)
|
|
66
68
|
|
|
67
69
|
raise ForbiddenError, "Mutation is not authorized"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
module PostgreSQL
|
|
5
|
+
class PublicationGenerator
|
|
6
|
+
DEFAULT_NAME = "zero_data"
|
|
7
|
+
|
|
8
|
+
def initialize(name: DEFAULT_NAME, published_schema: nil)
|
|
9
|
+
@name = name.to_s
|
|
10
|
+
@published_schema = PublishedSchema.new(
|
|
11
|
+
published_schema || ZeroRailsAdapter.configuration.published_schema.call,
|
|
12
|
+
zero_key: ZeroRailsAdapter.configuration.zero_key
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sql
|
|
17
|
+
entries = @published_schema.models.map do |model|
|
|
18
|
+
connection = model.connection
|
|
19
|
+
quoted_columns = @published_schema.column_names_for(model).map do |column|
|
|
20
|
+
connection.quote_column_name(column)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
"#{connection.quote_table_name(model.table_name)} (#{quoted_columns.join(', ')})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
raise UnsafePublicationError, "Published schema must include at least one table" if entries.empty?
|
|
27
|
+
|
|
28
|
+
connection = @published_schema.models.first.connection
|
|
29
|
+
<<~SQL
|
|
30
|
+
CREATE PUBLICATION #{connection.quote_column_name(@name)} FOR TABLE
|
|
31
|
+
#{entries.join(",\n ")};
|
|
32
|
+
SQL
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
module ZeroRailsAdapter
|
|
4
4
|
class Processor
|
|
5
5
|
CLEANUP_MUTATION_NAME = "_zero_cleanupResults"
|
|
6
|
+
DATABASE_ERROR_MESSAGE = "Database error"
|
|
7
|
+
INTERNAL_ERROR_MESSAGE = "Internal server error"
|
|
8
|
+
PERSISTABLE_MUTATION_ERRORS = [
|
|
9
|
+
ActiveModel::UnknownAttributeError,
|
|
10
|
+
ActiveModel::ValidationError,
|
|
11
|
+
ActiveRecord::RecordInvalid,
|
|
12
|
+
ActiveRecord::RecordNotDestroyed,
|
|
13
|
+
ActiveRecord::RecordNotFound,
|
|
14
|
+
ActiveRecord::RecordNotSaved
|
|
15
|
+
].freeze
|
|
6
16
|
|
|
7
17
|
attr_reader :request, :context, :storage
|
|
8
18
|
|
|
@@ -47,16 +57,20 @@ module ZeroRailsAdapter
|
|
|
47
57
|
mutation_ids: request.mutation_ids.drop(processed_count)
|
|
48
58
|
)
|
|
49
59
|
rescue ActiveRecord::ActiveRecordError => error
|
|
60
|
+
mutation_ids = request.mutation_ids.drop(processed_count)
|
|
61
|
+
log_push_failure(error, reason: "database", mutation_ids:)
|
|
50
62
|
push_failed(
|
|
51
63
|
reason: "database",
|
|
52
|
-
message:
|
|
53
|
-
mutation_ids:
|
|
64
|
+
message: DATABASE_ERROR_MESSAGE,
|
|
65
|
+
mutation_ids:
|
|
54
66
|
)
|
|
55
67
|
rescue StandardError => error
|
|
68
|
+
mutation_ids = request.mutation_ids.drop(processed_count)
|
|
69
|
+
log_push_failure(error, reason: "internal", mutation_ids:)
|
|
56
70
|
push_failed(
|
|
57
71
|
reason: "internal",
|
|
58
|
-
message:
|
|
59
|
-
mutation_ids:
|
|
72
|
+
message: INTERNAL_ERROR_MESSAGE,
|
|
73
|
+
mutation_ids:
|
|
60
74
|
)
|
|
61
75
|
end
|
|
62
76
|
|
|
@@ -84,7 +98,7 @@ module ZeroRailsAdapter
|
|
|
84
98
|
already_processed_response(mutation, error)
|
|
85
99
|
rescue OutOfOrderMutationError
|
|
86
100
|
raise
|
|
87
|
-
rescue
|
|
101
|
+
rescue ApplicationError, *PERSISTABLE_MUTATION_ERRORS => error
|
|
88
102
|
application_error = normalize_application_error(error)
|
|
89
103
|
begin
|
|
90
104
|
persist_failure(mutation, application_error)
|
|
@@ -138,8 +152,10 @@ module ZeroRailsAdapter
|
|
|
138
152
|
def normalize_application_error(error)
|
|
139
153
|
return error if error.is_a?(ApplicationError)
|
|
140
154
|
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
model = error.record if error.respond_to?(:record)
|
|
156
|
+
model ||= error.model if error.respond_to?(:model)
|
|
157
|
+
if model.respond_to?(:errors)
|
|
158
|
+
return ApplicationError.new(error.message, details: model.errors.to_hash)
|
|
143
159
|
end
|
|
144
160
|
|
|
145
161
|
ApplicationError.new(error.message)
|
|
@@ -174,5 +190,17 @@ module ZeroRailsAdapter
|
|
|
174
190
|
"mutationIDs" => mutation_ids
|
|
175
191
|
}
|
|
176
192
|
end
|
|
193
|
+
|
|
194
|
+
def log_push_failure(error, reason:, mutation_ids:)
|
|
195
|
+
ZeroRailsAdapter.configuration.logger&.error(
|
|
196
|
+
"ZeroRailsAdapter push failed: " \
|
|
197
|
+
"reason=#{reason} request_id=#{request.request_id.inspect} " \
|
|
198
|
+
"mutation_ids=#{mutation_ids.inspect} " \
|
|
199
|
+
"#{error.class}: #{error.message}\n" \
|
|
200
|
+
"#{Array(error.backtrace).join("\n")}"
|
|
201
|
+
)
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
177
205
|
end
|
|
178
206
|
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class PublishedSchema
|
|
5
|
+
SUPPORTED_COLUMN_TYPES = %i[
|
|
6
|
+
bigint boolean date datetime decimal enum float integer json jsonb
|
|
7
|
+
string text time timestamp uuid
|
|
8
|
+
].freeze
|
|
9
|
+
FORBIDDEN_COLUMN_NAMES = %w[
|
|
10
|
+
access_token api_key api_key_digest device_token key_digest
|
|
11
|
+
password password_digest password_hash refresh_token secret secret_key
|
|
12
|
+
token token_digest
|
|
13
|
+
].freeze
|
|
14
|
+
FORBIDDEN_TABLE_PREFIXES = %w[
|
|
15
|
+
action_mailbox_ active_storage_
|
|
16
|
+
].freeze
|
|
17
|
+
FORBIDDEN_TABLE_NAMES = %w[
|
|
18
|
+
user_login_change_keys user_lockouts user_password_reset_keys
|
|
19
|
+
user_previous_password_hashes user_recovery_codes user_remember_keys
|
|
20
|
+
user_verification_keys
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :models
|
|
24
|
+
|
|
25
|
+
def initialize(mapping, zero_key: ZeroRailsAdapter.configuration.zero_key)
|
|
26
|
+
value = mapping.respond_to?(:call) ? mapping.call : mapping
|
|
27
|
+
unless value.respond_to?(:to_h)
|
|
28
|
+
raise UnsafePublicationError,
|
|
29
|
+
"Published schema must be a model-to-columns mapping"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@zero_key = zero_key
|
|
33
|
+
@columns_by_model = value.to_h.each_with_object({}) do |(model, names), result|
|
|
34
|
+
validate_model!(model)
|
|
35
|
+
result[model] = validate_columns!(model, names)
|
|
36
|
+
end
|
|
37
|
+
@models = @columns_by_model.keys.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def empty?
|
|
41
|
+
models.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def column_names_for(model)
|
|
45
|
+
columns_for(model).map(&:name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def columns_for(model)
|
|
49
|
+
@columns_by_model.fetch(model)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def zero_keys_for(model)
|
|
53
|
+
keys = Array(@zero_key.call(model)).compact.map(&:to_s)
|
|
54
|
+
return keys.freeze if keys.any?
|
|
55
|
+
|
|
56
|
+
label = model.name.presence || model.table_name
|
|
57
|
+
raise UnsafePublicationError, "#{label} must define at least one Zero key"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def validate_model!(model)
|
|
63
|
+
unless model.is_a?(Class) &&
|
|
64
|
+
model < ActiveRecord::Base &&
|
|
65
|
+
!model.abstract_class?
|
|
66
|
+
raise UnsafePublicationError,
|
|
67
|
+
"#{model.inspect} is not an Active Record model"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if FORBIDDEN_TABLE_PREFIXES.any? { |prefix| model.table_name.start_with?(prefix) }
|
|
71
|
+
raise UnsafePublicationError,
|
|
72
|
+
"#{model.table_name} is an internal framework table"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if FORBIDDEN_TABLE_NAMES.include?(model.table_name)
|
|
76
|
+
raise UnsafePublicationError,
|
|
77
|
+
"#{model.table_name} is an authentication table"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_columns!(model, names)
|
|
82
|
+
names = Array(names).map(&:to_s).uniq
|
|
83
|
+
label = model.name.presence || model.table_name
|
|
84
|
+
raise UnsafePublicationError, "#{label} must publish at least one column" if names.empty?
|
|
85
|
+
|
|
86
|
+
unknown = names - model.column_names
|
|
87
|
+
if unknown.any?
|
|
88
|
+
raise UnsafePublicationError, "#{label}.#{unknown.first} does not exist"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
forbidden = names.find { |name| FORBIDDEN_COLUMN_NAMES.include?(name) }
|
|
92
|
+
if forbidden
|
|
93
|
+
raise UnsafePublicationError, "#{label}.#{forbidden} is forbidden"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
columns = names.map { |name| model.columns_hash.fetch(name) }
|
|
97
|
+
unsupported = columns.find do |column|
|
|
98
|
+
!SUPPORTED_COLUMN_TYPES.include?(column.type.to_sym)
|
|
99
|
+
end
|
|
100
|
+
if unsupported
|
|
101
|
+
raise UnsafePublicationError,
|
|
102
|
+
"#{label}.#{unsupported.name} uses unsupported PostgreSQL type " \
|
|
103
|
+
"#{unsupported.sql_type}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
missing_keys = Array(model.primary_key).compact.map(&:to_s) - names
|
|
107
|
+
if missing_keys.any?
|
|
108
|
+
key_label = missing_keys.one? ? "column" : "columns"
|
|
109
|
+
raise UnsafePublicationError,
|
|
110
|
+
"#{label} publication must include primary key #{key_label} " \
|
|
111
|
+
"#{missing_keys.join(', ')}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
validate_zero_key!(model, names, label)
|
|
115
|
+
columns.freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_zero_key!(model, published_names, label)
|
|
119
|
+
keys = zero_keys_for(model)
|
|
120
|
+
missing_keys = keys - published_names
|
|
121
|
+
if missing_keys.any?
|
|
122
|
+
key_label = missing_keys.one? ? "column" : "columns"
|
|
123
|
+
raise UnsafePublicationError,
|
|
124
|
+
"#{label} publication must include Zero key #{key_label} " \
|
|
125
|
+
"#{missing_keys.join(', ')}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
active_record_keys = Array(model.primary_key).compact.map(&:to_s)
|
|
129
|
+
return if keys == active_record_keys
|
|
130
|
+
|
|
131
|
+
columns = keys.map { |key| model.columns_hash.fetch(key) }
|
|
132
|
+
unique_index = model.connection.indexes(model.table_name).any? do |index|
|
|
133
|
+
index.unique &&
|
|
134
|
+
index.where.blank? &&
|
|
135
|
+
Array(index.columns).map(&:to_s) == keys
|
|
136
|
+
end
|
|
137
|
+
return if columns.none?(&:null) && unique_index
|
|
138
|
+
|
|
139
|
+
raise UnsafePublicationError,
|
|
140
|
+
"#{label} Zero key #{keys.join(', ')} must be backed by a unique, non-null index"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Relationship
|
|
5
|
+
Hop = Struct.new(
|
|
6
|
+
:source_fields,
|
|
7
|
+
:destination,
|
|
8
|
+
:destination_fields,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
attr_reader :source, :name, :kind, :hops
|
|
13
|
+
|
|
14
|
+
def self.from_definition(definition, published_schema:)
|
|
15
|
+
unless definition.respond_to?(:to_h)
|
|
16
|
+
raise InvalidRelationshipError, "Relationship definition must be an object"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attributes = definition.to_h.symbolize_keys
|
|
20
|
+
hop_definitions = if attributes.key?(:through)
|
|
21
|
+
Array(attributes[:through])
|
|
22
|
+
else
|
|
23
|
+
[{
|
|
24
|
+
source_fields: attributes[:source_fields],
|
|
25
|
+
destination: attributes[:destination],
|
|
26
|
+
destination_fields: attributes[:destination_fields]
|
|
27
|
+
}]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
new(
|
|
31
|
+
source: attributes[:source],
|
|
32
|
+
name: attributes[:name],
|
|
33
|
+
kind: attributes[:kind],
|
|
34
|
+
hops: hop_definitions,
|
|
35
|
+
published_schema:
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(source:, name:, kind:, hops:, published_schema:)
|
|
40
|
+
@source = source
|
|
41
|
+
@name = name.to_s
|
|
42
|
+
@kind = kind.to_s
|
|
43
|
+
@published_schema = published_schema
|
|
44
|
+
|
|
45
|
+
validate_header!
|
|
46
|
+
@hops = validate_hops!(hops).freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def validate_header!
|
|
52
|
+
unless @published_schema.models.include?(source)
|
|
53
|
+
raise InvalidRelationshipError,
|
|
54
|
+
"Relationship source #{model_label(source)} is not published"
|
|
55
|
+
end
|
|
56
|
+
if name.empty?
|
|
57
|
+
raise InvalidRelationshipError, "Relationship name must not be empty"
|
|
58
|
+
end
|
|
59
|
+
unless %w[one many].include?(kind)
|
|
60
|
+
raise InvalidRelationshipError,
|
|
61
|
+
"Relationship #{name} kind must be one or many"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_hops!(definitions)
|
|
66
|
+
definitions = Array(definitions)
|
|
67
|
+
if definitions.empty?
|
|
68
|
+
raise InvalidRelationshipError,
|
|
69
|
+
"Relationship #{name} must define at least one hop"
|
|
70
|
+
end
|
|
71
|
+
if definitions.length > 2
|
|
72
|
+
raise InvalidRelationshipError,
|
|
73
|
+
"Relationship #{name} supports at most two hops"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
current_source = source
|
|
77
|
+
definitions.map do |definition|
|
|
78
|
+
unless definition.respond_to?(:to_h)
|
|
79
|
+
raise InvalidRelationshipError,
|
|
80
|
+
"Relationship #{name} hop must be an object"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
attributes = definition.to_h.symbolize_keys
|
|
84
|
+
source_fields = normalize_fields(attributes[:source_fields])
|
|
85
|
+
destination = attributes[:destination]
|
|
86
|
+
destination_fields = normalize_fields(attributes[:destination_fields])
|
|
87
|
+
|
|
88
|
+
validate_model!(destination)
|
|
89
|
+
validate_fields!(current_source, source_fields, "source")
|
|
90
|
+
validate_fields!(destination, destination_fields, "destination")
|
|
91
|
+
if source_fields.length != destination_fields.length
|
|
92
|
+
raise InvalidRelationshipError,
|
|
93
|
+
"Relationship #{name} hop fields must have matching arity"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
current_source = destination
|
|
97
|
+
Hop.new(source_fields:, destination:, destination_fields:).freeze
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_model!(model)
|
|
102
|
+
return if @published_schema.models.include?(model)
|
|
103
|
+
|
|
104
|
+
raise InvalidRelationshipError,
|
|
105
|
+
"Relationship #{name} destination #{model_label(model)} is not published"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_fields!(model, fields, side)
|
|
109
|
+
if fields.empty?
|
|
110
|
+
raise InvalidRelationshipError,
|
|
111
|
+
"Relationship #{name} #{side} fields must not be empty"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
missing = fields - @published_schema.column_names_for(model)
|
|
115
|
+
return if missing.empty?
|
|
116
|
+
|
|
117
|
+
raise InvalidRelationshipError,
|
|
118
|
+
"Relationship #{name} #{side} field #{missing.first} is not published"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def normalize_fields(value)
|
|
122
|
+
Array(value).compact.map(&:to_s)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def model_label(model)
|
|
126
|
+
model.respond_to?(:name) && model.name.present? ? model.name : model.inspect
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|