motor-admin 0.1.25 → 0.1.32

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d25cd72aeb7206cf66b1d9c632491ad8e0e15a356c59e3234f4fcac2ab8e6960
4
- data.tar.gz: e4e0458c383689d39a8065b5c1d5ab48d57ebd19b5ea1f2c4f229bf1e070c466
3
+ metadata.gz: 9db6c3263064796fde708305f78fbc33db762534890eeeb79ec13e2954b8b54b
4
+ data.tar.gz: 462878ba2c5c21c77af910b27be67a1a8b7518ba73fd2b239c3309f88e9ec49a
5
5
  SHA512:
6
- metadata.gz: 82f524ca428f34514ad3f6361287de3faf8430da549a371196abaf0dbee0bd02068127be712a208708252d8aa7a7c41c84e8df6bc8f96f8ada747bdebc26a076
7
- data.tar.gz: b1921a2b38711019965f178120bff24dbe2f2465e750cdc592b2cac558820c51ce6c0addd9ca414ed4d3c90b4bd2c9aa15c112ed4072181fa47f8534d84b8a2b
6
+ metadata.gz: 457cfc674fa8845a9a1e21e6177a38a9159c92ad021ffb6af2504eb1df326cf5325efad85efed341082e9c3e3a863aba408d456626f5da167671e60af93f3416
7
+ data.tar.gz: e13a4a74752db108786a9625d3ea3e851e6bdcc4a7b6ac55f041d3cd753b5993f61e8d825038846603718483733888258e734d2fbc204b27b0149b2c751d0b46
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module WrapIoParams
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action :wrap_io_params, only: %i[update create]
9
+ end
10
+
11
+ private
12
+
13
+ def wrap_io_params(hash = params)
14
+ hash.each do |key, value|
15
+ if key == 'io'
16
+ hash[key] = StringIO.new(value.encode('ISO-8859-1'))
17
+ elsif value.is_a?(ActionController::Parameters)
18
+ wrap_io_params(value)
19
+ end
20
+ end
21
+
22
+ hash
23
+ end
24
+ end
25
+ end
@@ -2,16 +2,15 @@
2
2
 
3
3
  module Motor
4
4
  class ActiveStorageAttachmentsController < ApiBaseController
5
+ include Motor::WrapIoParams
6
+
5
7
  wrap_parameters :data, except: %i[include fields]
6
8
 
7
9
  load_and_authorize_resource :attachment, class: 'ActiveStorage::Attachment', parent: false
8
10
 
9
11
  def create
10
- if @attachment.record.respond_to?("#{@attachment.name}_attachment=") || @attachment.record.respond_to?("#{@attachment.name}_attachments=")
11
- @attachment.record.public_send(@attachment.name).attach(
12
- io: StringIO.new(params.dig(:data, :file, :io).to_s.encode('ISO-8859-1')),
13
- filename: params.dig(:data, :file, :filename)
14
- )
12
+ if attachable?(@attachment.record)
13
+ @attachment.record.public_send(@attachment.name).attach(file_params)
15
14
 
16
15
  head :ok
17
16
  else
@@ -21,8 +20,21 @@ module Motor
21
20
 
22
21
  private
23
22
 
23
+ def attachable?(record)
24
+ record.respond_to?("#{@attachment.name}_attachment=") ||
25
+ record.respond_to?("#{@attachment.name}_attachments=")
26
+ end
27
+
28
+ def file_params
29
+ params.require(:data).require(:file).permit(:io, :filename).to_h
30
+ end
31
+
24
32
  def attachment_params
25
- params.require(:data).except(:file).permit!
33
+ if params[:data].present?
34
+ params.require(:data).except(:file).permit!
35
+ else
36
+ {}
37
+ end
26
38
  end
27
39
  end
28
40
  end
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Motor
4
4
  class DataController < ApiBaseController
5
+ include Motor::WrapIoParams
6
+
5
7
  INSTANCE_VARIABLE_NAME = 'resource'
6
8
 
7
9
  wrap_parameters :data, except: %i[include fields]
8
10
 
9
11
  before_action :load_and_authorize_resource
10
12
  before_action :load_and_authorize_association
11
- before_action :wrap_io_params
12
13
 
13
14
  def index
14
15
  @resources = Motor::ApiQuery.call(@resources, params)
@@ -27,12 +28,20 @@ module Motor
27
28
  @resource.save!
28
29
 
29
30
  render json: { data: Motor::ApiQuery::BuildJson.call(@resource, params) }
31
+ rescue ActiveRecord::RecordInvalid
32
+ render json: { errors: @resource.errors }, status: :unprocessable_entity
33
+ rescue StandardError => e
34
+ render json: { errors: [e.message] }, status: :unprocessable_entity
30
35
  end
31
36
 
32
37
  def update
33
38
  @resource.update!(resource_params)
34
39
 
35
40
  render json: { data: Motor::ApiQuery::BuildJson.call(@resource, params) }
41
+ rescue ActiveRecord::RecordInvalid
42
+ render json: { errors: @resource.errors }, status: :unprocessable_entity
43
+ rescue StandardError => e
44
+ render json: { errors: [e.message] }, status: :unprocessable_entity
36
45
  end
37
46
 
38
47
  def destroy
@@ -77,6 +86,8 @@ module Motor
77
86
  self,
78
87
  options
79
88
  ).load_and_authorize_resource
89
+ rescue StandardError => e
90
+ render json: { errors: [e.message] }, status: :unprocessable_entity
80
91
  end
81
92
 
82
93
  def load_and_authorize_association
@@ -96,6 +107,8 @@ module Motor
96
107
  else
97
108
  render json: { message: 'Unknown association' }, status: :not_found
98
109
  end
110
+ rescue StandardError => e
111
+ render json: { errors: [e.message] }, status: :unprocessable_entity
99
112
  end
100
113
 
101
114
  def resource_params
@@ -105,17 +118,5 @@ module Motor
105
118
  {}
106
119
  end
107
120
  end
108
-
109
- def wrap_io_params(hash = params)
110
- hash.each do |key, value|
111
- if key == 'io'
112
- hash[key] = StringIO.new(value.encode('ISO-8859-1'))
113
- elsif value.is_a?(ActionController::Parameters)
114
- wrap_io_params(value)
115
- end
116
- end
117
-
118
- hash
119
- end
120
121
  end
121
122
  end
@@ -77,7 +77,7 @@
77
77
  <% if @query_result.data.length > 1 %>
78
78
  <p style="margin: 7px 0; float: left">Showing <%= @query_result.data.first(40).size %> of <%= @query_result.data.size %> rows</p>
79
79
  <% end %>
80
- <a href="<%= Motor::Admin.routes.url_helpers.motor_ui_query_url(@alert.query, { host: 'localhost:3000'}.merge(Rails.application.config.action_mailer.default_url_options || {})) %>" target="blank" style="margin: 7px 0; float: right">Open query </a>
80
+ <a href="<%= Motor::Admin.routes.url_helpers.motor_ui_query_url(@alert.query, { host: 'localhost:3000' }.merge(Rails.application.config.action_mailer.default_url_options || {})) %>" target="blank" style="margin: 7px 0; float: right">Open query </a>
81
81
  <table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
82
82
  <tr>
83
83
  <td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px; max-width: 0px; overflow: scroll">
data/config/routes.rb CHANGED
@@ -47,15 +47,4 @@ Motor::Admin.routes.draw do
47
47
  end
48
48
  end
49
49
 
50
- Motor::Api.routes.draw do
51
- namespace :motor, path: '' do
52
- resources :resources, path: '/:resource',
53
- only: %i[index show update create destroy],
54
- controller: 'data' do
55
- put '/:method', to: 'data#execute'
56
- resources :association, path: '/:association',
57
- only: %i[index create],
58
- controller: 'data'
59
- end
60
- end
61
- end
50
+ ActiveSupport::Notifications.instrument('motor.routes.loaded')
@@ -16,6 +16,8 @@ module Motor
16
16
 
17
17
  def copy_migration
18
18
  migration_template 'install.rb', 'db/migrate/install_motor_admin.rb'
19
+
20
+ route "mount Motor::Admin => '/motor_admin'"
19
21
  end
20
22
  end
21
23
  end
data/lib/motor.rb CHANGED
@@ -45,7 +45,7 @@ end
45
45
 
46
46
  require 'motor/version'
47
47
  require 'motor/admin'
48
- require 'motor/api'
48
+ # require 'motor/api'
49
49
  require 'motor/assets'
50
50
  require 'motor/build_schema'
51
51
  require 'motor/api_query'
@@ -21,8 +21,7 @@ module Motor
21
21
  'text' => 'string',
22
22
  'citext' => 'string',
23
23
  'jsonb' => 'json',
24
- 'timestamp' => 'datetime',
25
- 'money' => 'currency'
24
+ 'timestamp' => 'datetime'
26
25
  }.freeze
27
26
 
28
27
  module_function
data/lib/motor/admin.rb CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  module Motor
4
4
  class Admin < ::Rails::Engine
5
+ initializer 'motor.startup_message' do
6
+ ActiveSupport::Notifications.subscribe('motor.routes.loaded') do
7
+ next unless Motor.server?
8
+
9
+ if Rails.application.routes.url_helpers.respond_to?(:motor_admin_path)
10
+ url =
11
+ begin
12
+ Rails.application.routes.url_helpers.motor_admin_url
13
+ rescue ArgumentError
14
+ Rails.application.routes.url_helpers.motor_admin_path
15
+ end
16
+
17
+ puts
18
+ puts "⚡ Motor Admin is starting under #{url}"
19
+ puts
20
+ end
21
+ end
22
+ end
23
+
5
24
  initializer 'motor.filter_params' do
6
25
  Rails.application.config.filter_parameters += %i[io]
7
26
  end
@@ -22,10 +41,10 @@ module Motor
22
41
  ::Digest::SHA256.hexdigest(username),
23
42
  ::Digest::SHA256.hexdigest(ENV['MOTOR_AUTH_USERNAME'].to_s)
24
43
  ) &
25
- ActiveSupport::SecurityUtils.secure_compare(
26
- ::Digest::SHA256.hexdigest(password),
27
- ::Digest::SHA256.hexdigest(ENV['MOTOR_AUTH_PASSWORD'].to_s)
28
- )
44
+ ActiveSupport::SecurityUtils.secure_compare(
45
+ ::Digest::SHA256.hexdigest(password),
46
+ ::Digest::SHA256.hexdigest(ENV['MOTOR_AUTH_PASSWORD'].to_s)
47
+ )
29
48
  end
30
49
  end
31
50
 
@@ -29,9 +29,11 @@ module Motor
29
29
  hash = {}
30
30
 
31
31
  path.split('.').reduce(hash) do |acc, part|
32
- acc[part] = {}
32
+ acc_hash = {}
33
33
 
34
- acc[part]
34
+ acc[part] = acc_hash
35
+
36
+ acc_hash
35
37
  end
36
38
 
37
39
  accumulator.deep_merge(hash)
@@ -45,20 +47,25 @@ module Motor
45
47
  return if params[:fields].blank?
46
48
 
47
49
  model = rel.is_a?(ActiveRecord::Relation) ? rel.klass : rel.class
48
- model_name = model.name.underscore
49
50
 
50
51
  params[:fields].each do |key, fields|
51
52
  fields = fields.split(',') if fields.is_a?(String)
52
53
 
53
- if key == model_name || model_name.split('/').last == key
54
- json_params.merge!(build_fields_hash(model, fields))
55
- else
56
- hash = find_key_in_params(json_params, key)
54
+ merge_fields_params!(key, fields, json_params, model)
55
+ end
56
+ end
57
57
 
58
- fields_hash = build_fields_hash(model.reflections[key]&.klass, fields)
58
+ def merge_fields_params!(key, fields, json_params, model)
59
+ model_name = model.name.underscore
59
60
 
60
- hash.merge!(fields_hash)
61
- end
61
+ if key == model_name || model_name.split('/').last == key
62
+ json_params.merge!(build_fields_hash(model, fields))
63
+ else
64
+ hash = find_key_in_params(json_params, key)
65
+
66
+ fields_hash = build_fields_hash(model.reflections[key]&.klass, fields)
67
+
68
+ hash.merge!(fields_hash)
62
69
  end
63
70
  end
64
71
 
@@ -34,20 +34,29 @@ module Motor
34
34
 
35
35
  def normalize_filter_hash(hash)
36
36
  hash.each_with_object({}) do |(action, value), acc|
37
- acc[action] =
37
+ new_action, new_value =
38
38
  if value.is_a?(Hash)
39
- normalize_filter_hash(value)
39
+ [action, normalize_filter_hash(value)]
40
40
  else
41
- normalize_action_value(action, value)
41
+ normalize_action(action, value)
42
42
  end
43
+
44
+ acc[new_action] = new_value
45
+
46
+ acc
43
47
  end
44
48
  end
45
49
 
46
- def normalize_action_value(action, value)
47
- if %w[like ilike].include?(action)
48
- value.sub(LIKE_FILTER_VALUE_REGEXP, '%\1%')
50
+ def normalize_action(action, value)
51
+ case action
52
+ when 'contains'
53
+ ['ilike', value.sub(LIKE_FILTER_VALUE_REGEXP, '%\1%')]
54
+ when 'starts_with'
55
+ ['ilike', value.sub(LIKE_FILTER_VALUE_REGEXP, '\1%')]
56
+ when 'ends_with'
57
+ ['ilike', value.sub(LIKE_FILTER_VALUE_REGEXP, '%\1')]
49
58
  else
50
- value
59
+ [action, value]
51
60
  end
52
61
  end
53
62
  end
@@ -22,7 +22,11 @@ module Motor
22
22
  *path, _ = key.split('.')
23
23
 
24
24
  path.reduce(result) do |acc, fragment|
25
- acc[fragment] = {}
25
+ hash = {}
26
+
27
+ acc[fragment] = hash
28
+
29
+ hash
26
30
  end
27
31
  end
28
32
  end
@@ -3,6 +3,8 @@
3
3
  module Motor
4
4
  module BuildSchema
5
5
  module LoadFromRails
6
+ MUTEX = Mutex.new
7
+
6
8
  module_function
7
9
 
8
10
  def call
@@ -82,22 +84,26 @@ module Motor
82
84
  model.columns.map do |column|
83
85
  next if reference_columns.find { |c| c[:name] == column.name }
84
86
 
85
- {
86
- name: column.name,
87
- display_name: column.name.humanize,
88
- column_type: ActiveRecordUtils::Types::UNIFIED_TYPES[column.type.to_s] || column.type.to_s,
89
- access_type: COLUMN_NAME_ACCESS_TYPES.fetch(column.name, ColumnAccessTypes::READ_WRITE),
90
- default_value: default_attrs[column.name],
91
- validators: fetch_validators(model, column.name),
92
- reference: nil,
93
- format: {},
94
- virtual: false
95
- }
87
+ build_table_column(column, model, default_attrs)
96
88
  end.compact
97
89
 
98
90
  reference_columns + table_columns
99
91
  end
100
92
 
93
+ def build_table_column(column, model, default_attrs)
94
+ {
95
+ name: column.name,
96
+ display_name: column.name.humanize,
97
+ column_type: ActiveRecordUtils::Types::UNIFIED_TYPES[column.type.to_s] || column.type.to_s,
98
+ access_type: COLUMN_NAME_ACCESS_TYPES.fetch(column.name, ColumnAccessTypes::READ_WRITE),
99
+ default_value: default_attrs[column.name],
100
+ validators: fetch_validators(model, column.name),
101
+ reference: nil,
102
+ format: {},
103
+ virtual: false
104
+ }
105
+ end
106
+
101
107
  def fetch_reference_columns(model)
102
108
  default_attrs = model.new.attributes
103
109
 
@@ -110,32 +116,36 @@ module Motor
110
116
  next
111
117
  end
112
118
 
113
- column_name = ref.belongs_to? ? ref.foreign_key.to_s : name
114
-
115
119
  next if ref.klass.name == 'ActiveStorage::Blob'
116
120
 
117
- is_attachment = ref.klass.name == 'ActiveStorage::Attachment'
118
-
119
- {
120
- name: column_name,
121
- display_name: column_name.humanize,
122
- column_type: is_attachment ? 'file' : 'integer',
123
- access_type: ref.belongs_to? || is_attachment ? ColumnAccessTypes::READ_WRITE : ColumnAccessTypes::READ_ONLY,
124
- default_value: default_attrs[column_name],
125
- validators: fetch_validators(model, column_name),
126
- format: {},
127
- reference: {
128
- name: name,
129
- model_name: ref.klass.name.underscore,
130
- reference_type: ref.belongs_to? ? 'belongs_to' : 'has_one',
131
- foreign_key: ref.foreign_key,
132
- polymorphic: ref.polymorphic? || is_attachment
133
- },
134
- virtual: false
135
- }
121
+ build_reflection_column(name, model, ref, default_attrs)
136
122
  end.compact
137
123
  end
138
124
 
125
+ def build_reflection_column(name, model, ref, default_attrs)
126
+ column_name = ref.belongs_to? ? ref.foreign_key.to_s : name
127
+ is_attachment = ref.klass.name == 'ActiveStorage::Attachment'
128
+ access_type = ref.belongs_to? || is_attachment ? ColumnAccessTypes::READ_WRITE : ColumnAccessTypes::READ_ONLY
129
+
130
+ {
131
+ name: column_name,
132
+ display_name: column_name.humanize,
133
+ column_type: is_attachment ? 'file' : 'integer',
134
+ access_type: access_type,
135
+ default_value: default_attrs[column_name],
136
+ validators: fetch_validators(model, column_name, ref),
137
+ format: {},
138
+ reference: {
139
+ name: name,
140
+ model_name: ref.klass.name.underscore,
141
+ reference_type: ref.belongs_to? ? 'belongs_to' : 'has_one',
142
+ foreign_key: ref.foreign_key,
143
+ polymorphic: ref.polymorphic? || is_attachment
144
+ },
145
+ virtual: false
146
+ }
147
+ end
148
+
139
149
  def fetch_associations(model)
140
150
  model.reflections.map do |name, ref|
141
151
  next if ref.has_one? || ref.belongs_to?
@@ -162,30 +172,43 @@ module Motor
162
172
  end.compact
163
173
  end
164
174
 
165
- def fetch_validators(model, column_name)
166
- model.validators_on(column_name).map do |validator|
167
- case validator
168
- when ActiveModel::Validations::InclusionValidator
169
- { includes: validator.send(:delimiter) }
170
- when ActiveRecord::Validations::PresenceValidator
171
- { required: true }
172
- when ActiveModel::Validations::FormatValidator
173
- { format: JsRegex.new(validator.options[:with]).to_h.slice(:source, :options) }
174
- when ActiveRecord::Validations::LengthValidator
175
- { length: validator.options }
176
- when ActiveModel::Validations::NumericalityValidator
177
- { numeric: validator.options }
175
+ def fetch_validators(model, column_name, reflection = nil)
176
+ validators =
177
+ if reflection&.belongs_to? && !reflection.options[:optional]
178
+ [{ required: true }]
178
179
  else
179
- next
180
+ []
180
181
  end
182
+
183
+ validators += model.validators_on(column_name).map do |validator|
184
+ build_validator_hash(validator)
181
185
  end.compact
186
+
187
+ validators.uniq
188
+ end
189
+
190
+ def build_validator_hash(validator)
191
+ case validator
192
+ when ActiveModel::Validations::InclusionValidator
193
+ { includes: validator.send(:delimiter) }
194
+ when ActiveRecord::Validations::PresenceValidator
195
+ { required: true }
196
+ when ActiveModel::Validations::FormatValidator
197
+ { format: JsRegex.new(validator.options[:with]).to_h.slice(:source, :options) }
198
+ when ActiveRecord::Validations::LengthValidator
199
+ { length: validator.options }
200
+ when ActiveModel::Validations::NumericalityValidator
201
+ { numeric: validator.options }
202
+ end
182
203
  end
183
204
 
184
205
  def eager_load_models!
185
- if Rails::VERSION::MAJOR > 5 && defined?(Zeitwerk::Loader)
186
- Zeitwerk::Loader.eager_load_all
187
- else
188
- Rails.application.eager_load!
206
+ MUTEX.synchronize do
207
+ if Rails::VERSION::MAJOR > 5 && defined?(Zeitwerk::Loader)
208
+ Zeitwerk::Loader.eager_load_all
209
+ else
210
+ Rails.application.eager_load!
211
+ end
189
212
  end
190
213
  end
191
214
  end
@@ -27,40 +27,81 @@ module Motor
27
27
  def merge_model(model, configs)
28
28
  updated_model = model.merge(configs.slice(*RESOURCE_ATTRS))
29
29
 
30
- updated_model[:associations] = merge_by_name(
30
+ merge_actions!(updated_model, configs)
31
+ merge_assiciations!(updated_model, configs)
32
+ merge_columns!(updated_model, configs)
33
+ merge_tabs!(updated_model, configs)
34
+ merge_scopes!(updated_model, configs)
35
+
36
+ updated_model
37
+ end
38
+
39
+ # @param model [HashWithIndifferentAccess]
40
+ # @param configs [HashWithIndifferentAccess]
41
+ # @return [HashWithIndifferentAccess]
42
+ def merge_assiciations!(model, configs)
43
+ model[:associations] = merge_by_name(
31
44
  model[:associations],
32
45
  configs[:associations],
33
46
  {},
34
47
  ->(_) { true }
35
48
  )
36
49
 
37
- updated_model[:columns] = merge_by_name(
50
+ model
51
+ end
52
+
53
+ # @param model [HashWithIndifferentAccess]
54
+ # @param configs [HashWithIndifferentAccess]
55
+ # @return [HashWithIndifferentAccess]
56
+ def merge_columns!(model, configs)
57
+ model[:columns] = merge_by_name(
38
58
  model[:columns],
39
59
  configs[:columns],
40
60
  COLUMN_DEFAULTS,
41
61
  ->(scope) { !scope[:virtual] }
42
62
  )
43
63
 
44
- updated_model[:actions] = merge_by_name(
64
+ model
65
+ end
66
+
67
+ # @param model [HashWithIndifferentAccess]
68
+ # @param configs [HashWithIndifferentAccess]
69
+ # @return [HashWithIndifferentAccess]
70
+ def merge_actions!(model, configs)
71
+ model[:actions] = merge_by_name(
45
72
  model[:actions],
46
73
  configs[:actions],
47
74
  ACTION_DEFAULTS
48
75
  )
49
76
 
50
- updated_model[:tabs] = merge_by_name(
77
+ model
78
+ end
79
+
80
+ # @param model [HashWithIndifferentAccess]
81
+ # @param configs [HashWithIndifferentAccess]
82
+ # @return [HashWithIndifferentAccess]
83
+ def merge_tabs!(model, configs)
84
+ model[:tabs] = merge_by_name(
51
85
  model[:tabs],
52
86
  configs[:tabs],
53
87
  TAB_DEFAULTS
54
88
  )
55
89
 
56
- updated_model[:scopes] = merge_by_name(
90
+ model
91
+ end
92
+
93
+ # @param model [HashWithIndifferentAccess]
94
+ # @param configs [HashWithIndifferentAccess]
95
+ # @return [HashWithIndifferentAccess]
96
+ def merge_scopes!(model, configs)
97
+ model[:scopes] = merge_by_name(
57
98
  model[:scopes],
58
99
  configs[:scopes],
59
100
  SCOPE_DEFAULTS,
60
101
  ->(scope) { scope[:scope_type] != 'filter' }
61
102
  )
62
103
 
63
- updated_model
104
+ model
64
105
  end
65
106
 
66
107
  # @param defaults [Array<HashWithIndifferentAccess>]
@@ -77,47 +77,26 @@ module Motor
77
77
  normalized_preferences = existing_prefs.merge(normalized_preferences)
78
78
  normalized_preferences = reject_default(default_prefs, normalized_preferences)
79
79
 
80
- if new_prefs[:columns].present?
81
- normalized_preferences[:columns] = normalize_columns(
82
- default_prefs[:columns],
83
- existing_prefs.fetch(:columns, []),
84
- new_prefs.fetch(:columns, [])
85
- )
86
- end
80
+ normalize_configs!(normalized_preferences, :columns, default_prefs, existing_prefs, new_prefs)
81
+ normalize_configs!(normalized_preferences, :associations, default_prefs, existing_prefs, new_prefs)
82
+ normalize_configs!(normalized_preferences, :actions, default_prefs, existing_prefs, new_prefs)
83
+ normalize_configs!(normalized_preferences, :tabs, default_prefs, existing_prefs, new_prefs)
84
+ normalize_configs!(normalized_preferences, :scopes, default_prefs, existing_prefs, new_prefs)
87
85
 
88
- if new_prefs[:associations].present?
89
- normalized_preferences[:associations] = normalize_associations(
90
- default_prefs[:associations],
91
- existing_prefs.fetch(:associations, []),
92
- new_prefs.fetch(:associations, [])
93
- )
94
- end
86
+ normalized_preferences.compact
87
+ end
95
88
 
96
- if new_prefs[:actions].present?
97
- normalized_preferences[:actions] = normalize_actions(
98
- default_prefs[:actions],
99
- existing_prefs.fetch(:actions, []),
100
- new_prefs.fetch(:actions, [])
101
- )
102
- end
89
+ def normalize_configs!(preferences, configs_name, default_prefs, existing_prefs, new_prefs)
90
+ return preferences if new_prefs[configs_name].blank?
103
91
 
104
- if new_prefs[:tabs].present?
105
- normalized_preferences[:tabs] = normalize_tabs(
106
- default_prefs[:tabs],
107
- existing_prefs.fetch(:tabs, []),
108
- new_prefs.fetch(:tabs, [])
109
- )
110
- end
92
+ normalized_configs = public_send("normalize_#{configs_name}",
93
+ default_prefs[configs_name],
94
+ existing_prefs.fetch(configs_name, []),
95
+ new_prefs.fetch(configs_name, []))
111
96
 
112
- if new_prefs[:scopes].present?
113
- normalized_preferences[:scopes] = normalize_scopes(
114
- default_prefs[:scopes],
115
- existing_prefs.fetch(:scopes, []),
116
- new_prefs.fetch(:scopes, [])
117
- )
118
- end
97
+ preferences[configs_name] = normalized_configs
119
98
 
120
- normalized_preferences.compact
99
+ preferences
121
100
  end
122
101
 
123
102
  # @param default_columns [Array<HashWithIndifferentAccess>]
@@ -23,21 +23,29 @@ module Motor
23
23
 
24
24
  schema = sort_by_name(schema, configs['resources.order'])
25
25
 
26
- schema.map do |model|
27
- columns_order = configs["resources.#{model[:name]}.columns.order"]
28
- associations_order = configs["resources.#{model[:name]}.associations.order"]
29
- actions_order = configs["resources.#{model[:name]}.actions.order"]
30
- tabs_order = configs["resources.#{model[:name]}.tabs.order"]
31
- scopes_order = configs["resources.#{model[:name]}.scopes.order"]
26
+ schema.map { |model| reorder_model(model, configs) }
27
+ end
32
28
 
33
- model.merge(
34
- columns: sort_by_name(sort_columns(model[:columns]), columns_order, sort_alphabetically: false),
35
- associations: sort_by_name(model[:associations], associations_order),
36
- actions: sort_by_name(model[:actions], actions_order, sort_alphabetically: false),
37
- tabs: sort_by_name(model[:tabs], tabs_order, sort_alphabetically: false),
38
- scopes: sort_by_name(model[:scopes], scopes_order)
39
- )
40
- end
29
+ def reorder_model(model, configs)
30
+ order_configs = build_order_configs(model[:name], configs)
31
+
32
+ model.merge(
33
+ columns: sort_by_name(sort_columns(model[:columns]), order_configs[:columns], sort_alphabetically: false),
34
+ associations: sort_by_name(model[:associations], order_configs[:associations]),
35
+ actions: sort_by_name(model[:actions], order_configs[:actions], sort_alphabetically: false),
36
+ tabs: sort_by_name(model[:tabs], order_configs[:tabs], sort_alphabetically: false),
37
+ scopes: sort_by_name(model[:scopes], order_configs[:scopes])
38
+ )
39
+ end
40
+
41
+ def build_order_configs(model_name, configs)
42
+ {
43
+ columns: configs["resources.#{model_name}.columns.order"],
44
+ associations: configs["resources.#{model_name}.associations.order"],
45
+ actions: configs["resources.#{model_name}.actions.order"],
46
+ tabs: configs["resources.#{model_name}.tabs.order"],
47
+ scopes: configs["resources.#{model_name}.scopes.order"]
48
+ }
41
49
  end
42
50
 
43
51
  # @param list [Array<HashWithIndifferentAccess>]
data/lib/motor/queries.rb CHANGED
@@ -5,6 +5,7 @@ module Motor
5
5
  end
6
6
  end
7
7
 
8
+ require_relative './queries/render_sql_template'
8
9
  require_relative './queries/run_query'
9
10
  require_relative './queries/persistance'
10
11
  require_relative './queries/postgresql_exec_query'
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Queries
5
+ module RenderSqlTemplate
6
+ SECTION_OPEN_REGEXP = /{{([#^])\s*(\w+)}}.*\z/m.freeze
7
+ VARIABLE_REGEXP = /{{\s*(\w+)\s*}}/m.freeze
8
+
9
+ module_function
10
+
11
+ def call(sql, variables)
12
+ result = render_sections(sql, variables)
13
+
14
+ interpolate_variables(result, variables)
15
+ end
16
+
17
+ def interpolate_variables(sql, variables)
18
+ selected_variables = []
19
+
20
+ rendered =
21
+ sql.gsub(VARIABLE_REGEXP) do
22
+ variable_name = Regexp.last_match[1]
23
+
24
+ index = selected_variables.index { |name, _| name == variable_name }
25
+ selected_variables << [variable_name, variables[variable_name]] unless index
26
+
27
+ "$#{selected_variables.size}"
28
+ end
29
+
30
+ [rendered, selected_variables]
31
+ end
32
+
33
+ def render_sections(sql, variables)
34
+ sql.sub(SECTION_OPEN_REGEXP) do |e|
35
+ variable_name = Regexp.last_match[2]
36
+ is_negative = Regexp.last_match[1] == '^'
37
+
38
+ _, content, rest = e.split(build_section_close_regexp(variable_name), 3)
39
+
40
+ is_present = variables[variable_name].present?
41
+
42
+ render_sections(is_present ^ is_negative ? content + rest.to_s : rest, variables)
43
+ end
44
+ end
45
+
46
+ def build_section_close_regexp(variable_name)
47
+ %r{{{[#^/]s*#{Regexp.escape(variable_name)}\s*}}}m
48
+ end
49
+ end
50
+ end
51
+ end
@@ -3,8 +3,7 @@
3
3
  module Motor
4
4
  module Queries
5
5
  module RunQuery
6
- DEFAULT_LIMIT = 1_000_000
7
- INTERPOLATION_REGEXP = /{{(\w+)}}/.freeze
6
+ DEFAULT_LIMIT = 100_000
8
7
 
9
8
  QueryResult = Struct.new(:data, :columns, keyword_init: true)
10
9
 
@@ -14,6 +13,10 @@ module Motor
14
13
 
15
14
  module_function
16
15
 
16
+ # @param query [Motor::Query]
17
+ # @param variables_hash [Hash]
18
+ # @param limit [Integer]
19
+ # @return [Motor::Queries::RunQuery::QueryResult]
17
20
  def call(query, variables_hash: nil, limit: DEFAULT_LIMIT)
18
21
  variables_hash ||= {}
19
22
 
@@ -22,15 +25,27 @@ module Motor
22
25
  QueryResult.new(data: result.rows, columns: build_columns_hash(result))
23
26
  end
24
27
 
28
+ # @param query [Motor::Query]
29
+ # @param limit [Integer]
30
+ # @param variables_hash [Hash]
31
+ # @return [ActiveRecord::Result]
25
32
  def execute_query(query, limit, variables_hash)
33
+ result = nil
26
34
  statement = prepare_sql_statement(query, limit, variables_hash)
27
35
 
28
- case ActiveRecord::Base.connection
29
- when ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
30
- PostgresqlExecQuery.call(ActiveRecord::Base.connection, statement)
31
- else
32
- ActiveRecord::Base.connection.exec_query(*statement)
36
+ ActiveRecord::Base.transaction do
37
+ result =
38
+ case ActiveRecord::Base.connection
39
+ when ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
40
+ PostgresqlExecQuery.call(ActiveRecord::Base.connection, statement)
41
+ else
42
+ ActiveRecord::Base.connection.exec_query(*statement)
43
+ end
44
+
45
+ raise ActiveRecord::Rollback
33
46
  end
47
+
48
+ result
34
49
  end
35
50
 
36
51
  # @param result [ActiveRecord::Result]
@@ -47,27 +62,40 @@ module Motor
47
62
  end
48
63
  end
49
64
 
65
+ # @param query [Motor::Query]
66
+ # @param limit [Integer]
67
+ # @param variables_hash [Hash]
68
+ # @return [Array]
50
69
  def prepare_sql_statement(query, limit, variables_hash)
51
- variables = query.preferences.fetch(:variables, []).pluck(:name, :default_value)
52
-
53
- sql =
54
- query.sql_body.gsub(INTERPOLATION_REGEXP) do
55
- index = variables.index { |name, _| name == (Regexp.last_match[1]) } + 1
70
+ variables = merge_variable_default_values(query.preferences.fetch(:variables, []), variables_hash)
56
71
 
57
- "$#{index}"
58
- end
72
+ sql, query_variables = RenderSqlTemplate.call(query.sql_body, variables)
59
73
 
60
- attributes =
61
- variables.map do |variable_name, default_value|
62
- ActiveRecord::Relation::QueryAttribute.new(
63
- variable_name,
64
- variables_hash[variable_name] || default_value,
65
- ActiveRecord::Type::Value.new
66
- )
67
- end
74
+ attributes = build_statement_attributes(query_variables)
68
75
 
69
76
  [format(WITH_STATEMENT_TEMPLATE, sql_body: sql.strip.gsub(/;\z/, ''), limit: limit), 'SQL', attributes]
70
77
  end
78
+
79
+ # @param variables [Array<(String, Object)>]
80
+ # @return [Array<ActiveRecord::Relation::QueryAttribute>]
81
+ def build_statement_attributes(variables)
82
+ variables.map do |variable_name, value|
83
+ ActiveRecord::Relation::QueryAttribute.new(
84
+ variable_name,
85
+ value,
86
+ ActiveRecord::Type::Value.new
87
+ )
88
+ end
89
+ end
90
+
91
+ # @param variable_configs [Array<Hash>]
92
+ # @param variable_hash [Hash]
93
+ # @return [Hash]
94
+ def merge_variable_default_values(variable_configs, variables_hash)
95
+ variable_configs.each_with_object({}) do |variable, acc|
96
+ acc[variable[:name]] = variables_hash[variable[:name]] || variable[:default_value]
97
+ end
98
+ end
71
99
  end
72
100
  end
73
101
  end
@@ -27,22 +27,42 @@ module Motor
27
27
  {
28
28
  base_path: Motor::Admin.routes.url_helpers.motor_path,
29
29
  schema: Motor::BuildSchema.call,
30
- header_links: Motor::Config.find_by(key: 'header.links')&.value || [],
31
- queries: Motor::Query.all.active.preload(:tags)
32
- .as_json(only: %i[id name updated_at],
33
- include: { tags: { only: %i[id name] } }),
34
- dashboards: Motor::Dashboard.all.active.preload(:tags)
35
- .as_json(only: %i[id title updated_at],
36
- include: { tags: { only: %i[id name] } }),
37
- alerts: Motor::Alert.all.active.preload(:tags)
38
- .as_json(only: %i[id name is_enabled updated_at],
39
- include: { tags: { only: %i[id name] } }),
40
- forms: Motor::Form.all.active.preload(:tags)
41
- .as_json(only: %i[id name updated_at],
42
- include: { tags: { only: %i[id name] } })
30
+ header_links: header_links_data_hash,
31
+ queries: queries_data_hash,
32
+ dashboards: dashboards_data_hash,
33
+ alerts: alerts_data_hash,
34
+ forms: forms_data_hash
43
35
  }
44
36
  end
45
37
 
38
+ def header_links_data_hash
39
+ Motor::Config.find_by(key: 'header.links')&.value || []
40
+ end
41
+
42
+ def queries_data_hash
43
+ Motor::Query.all.active.preload(:tags)
44
+ .as_json(only: %i[id name updated_at],
45
+ include: { tags: { only: %i[id name] } })
46
+ end
47
+
48
+ def dashboards_data_hash
49
+ Motor::Dashboard.all.active.preload(:tags)
50
+ .as_json(only: %i[id title updated_at],
51
+ include: { tags: { only: %i[id name] } })
52
+ end
53
+
54
+ def alerts_data_hash
55
+ Motor::Alert.all.active.preload(:tags)
56
+ .as_json(only: %i[id name is_enabled updated_at],
57
+ include: { tags: { only: %i[id name] } })
58
+ end
59
+
60
+ def forms_data_hash
61
+ Motor::Form.all.active.preload(:tags)
62
+ .as_json(only: %i[id name updated_at],
63
+ include: { tags: { only: %i[id name] } })
64
+ end
65
+
46
66
  # @return [String]
47
67
  def cache_key
48
68
  ActiveRecord::Base.connection.execute(
data/lib/motor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Motor
4
- VERSION = '0.1.25'
4
+ VERSION = '0.1.32'
5
5
  end
@@ -5,9 +5,9 @@
5
5
  "fonts/ionicons.ttf?v=3.0.0-alpha.3": "fonts/ionicons.ttf",
6
6
  "fonts/ionicons.woff2?v=3.0.0-alpha.3": "fonts/ionicons.woff2",
7
7
  "fonts/ionicons.woff?v=3.0.0-alpha.3": "fonts/ionicons.woff",
8
- "main-07eb806daf063d260c47.css.gz": "main-07eb806daf063d260c47.css.gz",
9
- "main-07eb806daf063d260c47.js.LICENSE.txt": "main-07eb806daf063d260c47.js.LICENSE.txt",
10
- "main-07eb806daf063d260c47.js.gz": "main-07eb806daf063d260c47.js.gz",
11
- "main.css": "main-07eb806daf063d260c47.css",
12
- "main.js": "main-07eb806daf063d260c47.js"
8
+ "main-eea896c6f0ede15495f0.css.gz": "main-eea896c6f0ede15495f0.css.gz",
9
+ "main-eea896c6f0ede15495f0.js.LICENSE.txt": "main-eea896c6f0ede15495f0.js.LICENSE.txt",
10
+ "main-eea896c6f0ede15495f0.js.gz": "main-eea896c6f0ede15495f0.js.gz",
11
+ "main.css": "main-eea896c6f0ede15495f0.css",
12
+ "main.js": "main-eea896c6f0ede15495f0.js"
13
13
  }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: motor-admin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.25
4
+ version: 0.1.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pete Matsyburka
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-05-05 00:00:00.000000000 Z
11
+ date: 2021-05-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord-filter
@@ -132,6 +132,7 @@ files:
132
132
  - LICENSE
133
133
  - README.md
134
134
  - Rakefile
135
+ - app/controllers/concerns/motor/wrap_io_params.rb
135
136
  - app/controllers/motor/active_storage_attachments_controller.rb
136
137
  - app/controllers/motor/alerts_controller.rb
137
138
  - app/controllers/motor/api_base_controller.rb
@@ -182,7 +183,6 @@ files:
182
183
  - lib/motor/alerts/persistance.rb
183
184
  - lib/motor/alerts/scheduled_alerts_cache.rb
184
185
  - lib/motor/alerts/scheduler.rb
185
- - lib/motor/api.rb
186
186
  - lib/motor/api_query.rb
187
187
  - lib/motor/api_query/apply_scope.rb
188
188
  - lib/motor/api_query/build_json.rb
@@ -208,13 +208,14 @@ files:
208
208
  - lib/motor/queries.rb
209
209
  - lib/motor/queries/persistance.rb
210
210
  - lib/motor/queries/postgresql_exec_query.rb
211
+ - lib/motor/queries/render_sql_template.rb
211
212
  - lib/motor/queries/run_query.rb
212
213
  - lib/motor/tags.rb
213
214
  - lib/motor/ui_configs.rb
214
215
  - lib/motor/version.rb
215
216
  - ui/dist/fonts/ionicons.woff2
216
- - ui/dist/main-07eb806daf063d260c47.css.gz
217
- - ui/dist/main-07eb806daf063d260c47.js.gz
217
+ - ui/dist/main-eea896c6f0ede15495f0.css.gz
218
+ - ui/dist/main-eea896c6f0ede15495f0.js.gz
218
219
  - ui/dist/manifest.json
219
220
  homepage:
220
221
  licenses:
data/lib/motor/api.rb DELETED
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Motor
4
- class Api < ::Rails::Engine
5
- end
6
- end