Class: Mxrb::Runtime::DatabaseWorkspace

Inherits:
Object
  • Object
show all
Defined in:
lib/mxrb/runtime/database_workspace.rb

Overview

Materializes a private PostgreSQL database for one MPR. The Mendix Runtime owns schema synchronization; analyst access uses a separate read-only role. rubocop:disable Metrics/ClassLength

Constant Summary collapse

POSTGRES_IMAGE =
'postgres:13-alpine'
DATABASE =
'mxrb'
OWNER =
'mxrb_runtime'
READER =
'mxrb_reader'
NATIVE_INPUT_DIRECTORIES =
%w[
  mprcontents deployment javasource javascriptsource userlib vendorlib
  resources widgets theme theme-cache themesource
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(project_path, state_dir: nil, port: 55_432, runtime_port: 18_080, runner: nil, sleeper: nil) ⇒ DatabaseWorkspace

rubocop:disable Metrics/AbcSize, Metrics/ParameterLists

Raises:

  • (ArgumentError)


31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/mxrb/runtime/database_workspace.rb', line 31

def initialize(project_path, state_dir: nil, port: 55_432, runtime_port: 18_080, # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
               runner: nil, sleeper: nil)
  @project_path = File.expand_path(project_path)
  @project_id = Digest::SHA256.hexdigest(@project_path)[0, 12]
  @state_dir = File.expand_path(state_dir || default_state_dir)
  @port = Integer(port)
  @runtime_port = Integer(runtime_port)
  raise ArgumentError, 'database port must be between 1 and 65535' unless (1..65_535).cover?(@port)
  raise ArgumentError, 'runtime port must be between 1 and 65535' unless (1..65_535).cover?(@runtime_port)

  @runner = runner || method(:capture)
  @sleeper = sleeper || Kernel.method(:sleep)
  @plan = Toolchain.new(@project_path).plan
end

Instance Method Details

#connection_urlObject



146
147
148
149
# File 'lib/mxrb/runtime/database_workspace.rb', line 146

def connection_url
  secret = credentials.fetch('reader_password')
  "postgresql://#{READER}:#{secret}@127.0.0.1:#{@port}/#{DATABASE}"
end

#destroy(confirm: false) ⇒ Object

Raises:

  • (ArgumentError)


66
67
68
69
70
71
72
73
74
75
# File 'lib/mxrb/runtime/database_workspace.rb', line 66

def destroy(confirm: false)
  raise ArgumentError, 'destroy requires explicit confirmation' unless confirm

  remove_container(runtime_container)
  remove_container(database_container)
  remove_owned_resource('volume', database_volume)
  remove_owned_resource('network', network_name)
  FileUtils.rm_rf(@state_dir)
  info(running: false)
end

#downObject



60
61
62
63
64
# File 'lib/mxrb/runtime/database_workspace.rb', line 60

def down
  stop_container(runtime_container)
  stop_container(database_container)
  info(running: false)
end

#explain(sql, analyze: false) ⇒ Object

Uses PostgreSQL's machine-readable planner output. ANALYZE is opt-in because it executes the SELECT, although the analyst role remains in a read-only transaction and cannot mutate application data.



114
115
116
117
118
119
120
121
# File 'lib/mxrb/runtime/database_workspace.rb', line 114

def explain(sql, analyze: false)
  statement = read_only_statement(sql)
  configure_reader!
  payload = JSON.parse(run_explain(statement, analyze).strip)
  Oql::PlanAnalyzer.new(indexes: index_catalog).analyze(payload, analyzed: analyze)
rescue JSON::ParserError => e
  raise ToolchainError, "PostgreSQL returned invalid EXPLAIN JSON: #{e.message}"
end

#index_advice(limit: 50) ⇒ Object



134
135
136
# File 'lib/mxrb/runtime/database_workspace.rb', line 134

def index_advice(limit: 50)
  Oql::IndexAdvisor.new.analyze(workload(limit:))
end

#query(sql, write: false) ⇒ Object

Keeping the transaction envelope next to role selection makes the read-only default auditable. rubocop:disable Metrics/MethodLength

Raises:

  • (ArgumentError)


84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/mxrb/runtime/database_workspace.rb', line 84

def query(sql, write: false)
  raise ArgumentError, 'SQL must not be empty' if sql.to_s.strip.empty?
  raise ArgumentError, 'SQL contains a NUL byte' if sql.include?("\0")

  configure_reader! unless write
  role = write ? OWNER : READER
  command = [
    'docker', 'exec', database_container,
    'psql', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1',
    '--username', role, '--dbname', DATABASE
  ]
  command.concat(['--command', 'BEGIN READ ONLY']) unless write
  command.concat(['--command', sql])
  command.concat(['--command', 'COMMIT']) unless write
  run!(*command)
end

#query_rows(sql, params: {}) ⇒ Object

rubocop:enable Metrics/MethodLength



102
103
104
105
106
107
108
109
# File 'lib/mxrb/runtime/database_workspace.rb', line 102

def query_rows(sql, params: {})
  statement = read_only_statement(sql)
  statement, variables = bind_parameters(statement, params)

  configure_reader!
  output = run!(*query_rows_command(statement, variables))
  CSV.parse(output, headers: true).map(&:to_h).freeze
end

#shell_command(write: false) ⇒ Object



138
139
140
141
142
143
144
# File 'lib/mxrb/runtime/database_workspace.rb', line 138

def shell_command(write: false)
  role = write ? OWNER : READER
  [
    'docker', 'exec', '-it', database_container,
    'psql', '--no-psqlrc', '--username', role, '--dbname', DATABASE
  ].freeze
end

#statusObject



77
78
79
# File 'lib/mxrb/runtime/database_workspace.rb', line 77

def status
  info(running: container_running?(database_container) && container_running?(runtime_container))
end

#syncObject



58
# File 'lib/mxrb/runtime/database_workspace.rb', line 58

def sync = up(force_build: true)

#up(force_build: false) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
# File 'lib/mxrb/runtime/database_workspace.rb', line 46

def up(force_build: false)
  validate!
  prepare_state
  refresh_runtime_package(force_build)
  prepare_database!
  configure_reader!
  restart_runtime!
  wait_for_runtime!
  configure_reader!
  info(running: true)
end

#workload(limit: 20) ⇒ Object

Raises:

  • (ArgumentError)


123
124
125
126
127
128
129
130
131
132
# File 'lib/mxrb/runtime/database_workspace.rb', line 123

def workload(limit: 20)
  maximum = Integer(limit)
  raise ArgumentError, 'workload limit must be between 1 and 1000' unless (1..1000).cover?(maximum)

  Oql::WorkloadAnalyzer.new.analyze(
    query_rows: workload_queries(maximum),
    table_rows: workload_tables,
    index_rows: workload_indexes
  )
end