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.
@@ -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
- changes = writable(model, :update, attributes).except(*primary_keys(model))
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 = primary_keys(model)
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 primary key attributes: #{missing.join(', ')}",
90
- details: {"primaryKey" => missing}
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 primary_keys(model)
98
- Array(model.primary_key).map(&:to_s)
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
- return true unless callback
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: error.message,
53
- mutation_ids: request.mutation_ids.drop(processed_count)
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: error.message,
59
- mutation_ids: request.mutation_ids.drop(processed_count)
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 StandardError => error
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
- if error.respond_to?(:record) && error.record.respond_to?(:errors)
142
- return ApplicationError.new(error.message, details: error.record.errors.to_hash)
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