monq 0.1.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.
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mongoid"
4
+
5
+ module Monq
6
+ class Environment
7
+ attr_reader :name, :config, :client_name, :namespace, :client, :ssh_proxy
8
+
9
+ def initialize(name, config, first:, model_dir:)
10
+ @name = name
11
+ @config = config
12
+ @first = first
13
+ @model_dir = model_dir
14
+ @client_name = Config.client_name_for(name)
15
+ @namespace = config[:namespace]
16
+ @models = []
17
+ @sandbox_session = nil
18
+ end
19
+
20
+ def connect!
21
+ ensure_ssh_proxy!
22
+ opts = Config.client_options(client_config)
23
+ @client = Mongo::Client.new(opts[:hosts], **opts[:options].merge(database: opts[:database]))
24
+ Mongoid::Clients.set(client_name, @client)
25
+ Mongoid::Clients.set(:default, @client) if @first
26
+ hello!
27
+ end
28
+
29
+ def hello!
30
+ @client.database.command(hello: 1).documents.first
31
+ end
32
+
33
+ def ping?
34
+ hello!
35
+ true
36
+ rescue StandardError
37
+ false
38
+ end
39
+
40
+ def reconnect!
41
+ raise Error, "sandbox is active; run sandbox-quit first" if sandbox?
42
+
43
+ @ssh_proxy&.reconnect!
44
+ @client&.close
45
+ connect!
46
+ end
47
+
48
+ def namespace_module
49
+ @namespace_module ||= Object.const_set(namespace, Module.new)
50
+ end
51
+
52
+ def sandbox?
53
+ !@sandbox_session.nil?
54
+ end
55
+
56
+ def bind_session(session)
57
+ @sandbox_session = session
58
+ attach_client_session(session)
59
+ end
60
+
61
+ def clear_session
62
+ if @sandbox_session
63
+ @sandbox_session.abort_transaction rescue nil
64
+ @sandbox_session.end_session rescue nil
65
+ @sandbox_session = nil
66
+ end
67
+ detach_client_session
68
+ end
69
+
70
+ def load_models!
71
+ install_namespace_api!
72
+ path = File.join(@model_dir, "#{Config.prefix_for(name)}.rb")
73
+ unless File.file?(path)
74
+ warn "model file not found: #{path}"
75
+ return
76
+ end
77
+
78
+ namespace_module.module_eval(File.read(path), path)
79
+ bind_clients!
80
+ track_embedded_classes!
81
+ install_abbreviations!
82
+ end
83
+
84
+ def models
85
+ namespace_module.constants.filter_map do |const_name|
86
+ klass = namespace_module.const_get(const_name)
87
+ next unless klass.is_a?(Class) && klass < Mongoid::Document && !klass.embedded?
88
+ next unless klass.name.to_s.split("::").last == const_name.to_s
89
+
90
+ klass
91
+ end
92
+ end
93
+
94
+ def redefine!
95
+ raise Error, "sandbox is active; run sandbox-quit first" if sandbox?
96
+
97
+ tracked_classes.each do |klass|
98
+ Mongoid.models.delete(klass) if Mongoid.models.respond_to?(:delete)
99
+ end
100
+ tracked_names.each { |n| namespace_module.send(:remove_const, n) if namespace_module.const_defined?(n, false) }
101
+ @tracked = []
102
+ load_models!
103
+ end
104
+
105
+ private
106
+
107
+ def attach_client_session(session)
108
+ return unless @client
109
+
110
+ if @client.respond_to?(:session=)
111
+ @client.session = session
112
+ else
113
+ @client.instance_variable_set(:@monq_session, session)
114
+ end
115
+ end
116
+
117
+ def detach_client_session
118
+ return unless @client
119
+
120
+ if @client.respond_to?(:session=)
121
+ @client.session = nil
122
+ elsif @client.instance_variable_defined?(:@monq_session)
123
+ @client.remove_instance_variable(:@monq_session)
124
+ end
125
+ end
126
+
127
+ def ensure_ssh_proxy!
128
+ return unless config[:ssh]
129
+ return if @ssh_proxy
130
+
131
+ target = Config.ssh_forward_target(config)
132
+ ssh = config[:ssh]
133
+ @ssh_proxy = SSHProxy.new(
134
+ host: ssh[:host],
135
+ user: ssh[:user],
136
+ password: ssh[:password],
137
+ port: ssh[:port] || 22,
138
+ forward_host: target[:host],
139
+ forward_port: target[:port],
140
+ local_port: ssh[:local_port]
141
+ )
142
+ end
143
+
144
+ def client_config
145
+ return config unless @ssh_proxy
146
+
147
+ config.merge(ssh: config[:ssh].merge(local_port: @ssh_proxy.local_port, local_host: "127.0.0.1"))
148
+ end
149
+
150
+ def tracked_names
151
+ @tracked ||= []
152
+ end
153
+
154
+ def tracked_classes
155
+ tracked_names.filter_map do |n|
156
+ namespace_module.const_get(n) if namespace_module.const_defined?(n, false)
157
+ end.select { |k| k.is_a?(Class) }
158
+ end
159
+
160
+ def bind_clients!
161
+ namespace_module.constants.each do |const_name|
162
+ klass = namespace_module.const_get(const_name)
163
+ next unless klass.is_a?(Class) && klass < Mongoid::Document
164
+ next if klass.embedded?
165
+
166
+ current = klass.storage_options[:client] || klass.storage_options["client"]
167
+ next if current && current.to_sym != :default
168
+
169
+ klass.store_in klass.storage_options.merge(client: client_name)
170
+ tracked_names << const_name
171
+ end
172
+ end
173
+
174
+ def track_embedded_classes!
175
+ models.each do |klass|
176
+ relations = klass.embedded_relations
177
+ relations = klass.relations if relations.nil? || (relations.respond_to?(:empty?) && relations.empty? && klass.respond_to?(:relations))
178
+ Array(relations).each do |entry|
179
+ relation = entry.is_a?(Array) ? entry.last : entry
180
+ class_name = relation.respond_to?(:class_name) ? relation.class_name : relation[:class_name]
181
+ next if class_name.nil? || class_name.to_s.empty?
182
+
183
+ embedded = namespace_module.const_get(class_name) rescue nil
184
+ next unless embedded.is_a?(Class) && embedded < Mongoid::Document
185
+ next unless embedded.name.to_s.start_with?("#{namespace}::")
186
+
187
+ short = embedded.name.split("::").last.to_sym
188
+ tracked_names << short unless tracked_names.include?(short)
189
+ end
190
+ end
191
+ end
192
+
193
+ def install_abbreviations!
194
+ models.each do |klass|
195
+ abbr = klass.name.split("::").last.gsub(/[^A-Z]/, "")
196
+ abbr = "M" if abbr.empty?
197
+ candidate = abbr
198
+ n = 2
199
+ while namespace_module.const_defined?(candidate, false)
200
+ candidate = "#{abbr}#{n}"
201
+ n += 1
202
+ end
203
+ namespace_module.const_set(candidate, klass)
204
+ tracked_names << candidate.to_sym
205
+ end
206
+ end
207
+
208
+ def install_namespace_api!
209
+ env = self
210
+ mod = namespace_module
211
+ mod.define_singleton_method(:client) { env.client }
212
+ mod.define_singleton_method(:models) { env.models }
213
+ mod.define_singleton_method(:collections) { env.client.database.collection_names }
214
+ mod.define_singleton_method(:config) { env.config }
215
+ mod.define_singleton_method(:q) { |doc| env.client.database.command(doc) }
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Array
4
+ def t
5
+ puts Monq::Ext.table_for(hash_docs)
6
+ nil
7
+ end
8
+
9
+ def v
10
+ Monq::Ext.vertical(hash_docs)
11
+ end
12
+
13
+ # visidata comes later; always print the table so vd works without the binary.
14
+ def vd
15
+ t
16
+ end
17
+
18
+ def write_csv(filename)
19
+ require "csv"
20
+ docs = respond_to?(:hash_docs, true) ? hash_docs : self
21
+ File.open(filename, "w:UTF-8") do |file|
22
+ csv = CSV.new(file)
23
+ return if docs.empty?
24
+
25
+ keys = docs.first.keys
26
+ csv << keys.map(&:to_s)
27
+ docs.each { |row| csv << keys.map { |k| row[k].to_s } }
28
+ end
29
+ end
30
+
31
+ def write_excel(filename, sheet_name: "Sheet1")
32
+ require "caxlsx"
33
+ docs = hash_docs
34
+ package = Axlsx::Package.new
35
+ package.workbook.add_worksheet(name: sheet_name) do |sheet|
36
+ next if docs.empty?
37
+
38
+ keys = docs.first.keys
39
+ sheet.add_row(keys.map(&:to_s))
40
+ docs.each { |row| sheet.add_row(keys.map { |k| row[k].to_s }) }
41
+ end
42
+ package.serialize(filename)
43
+ end
44
+
45
+ private
46
+
47
+ def hash_docs
48
+ return self if empty?
49
+ return self if first.is_a?(Hash)
50
+
51
+ if first.respond_to?(:attributes)
52
+ map { |item| item.attributes }
53
+ else
54
+ self
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ module Ext
5
+ module Criteria
6
+ def t
7
+ Monq::Ext.display_criteria(self)
8
+ end
9
+
10
+ def v
11
+ docs, = Monq::Ext.materialize(self)
12
+ docs.v
13
+ end
14
+
15
+ def vd
16
+ t
17
+ end
18
+
19
+ # Full result set. Display methods cap at 500; export must not.
20
+ def write_csv(filename)
21
+ to_a.write_csv(filename)
22
+ end
23
+
24
+ def write_excel(filename, sheet_name: "Sheet1")
25
+ to_a.write_excel(filename, sheet_name: sheet_name)
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ Mongoid::Criteria.include(Monq::Ext::Criteria)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ module Ext
5
+ module Document
6
+ def t
7
+ [attributes].t
8
+ end
9
+
10
+ def v
11
+ [attributes].v
12
+ end
13
+
14
+ def vd
15
+ [attributes].vd
16
+ end
17
+
18
+ def write_csv(filename)
19
+ [self].write_csv(filename)
20
+ end
21
+
22
+ def write_excel(filename, sheet_name: "Sheet1")
23
+ [self].write_excel(filename, sheet_name: sheet_name)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ Mongoid::Document.include(Monq::Ext::Document)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kernel
4
+ def models
5
+ Monq::App.instance.environments.flat_map { |env| env.models }
6
+ rescue StandardError
7
+ []
8
+ end
9
+
10
+ def model_names
11
+ models.map(&:name)
12
+ end
13
+
14
+ def within_namespace(pattern, &block)
15
+ if pattern.is_a?(Module)
16
+ pattern.module_eval(&block)
17
+ return
18
+ end
19
+
20
+ env = find_environment { |candidate| name_matches?(candidate.namespace, pattern) }
21
+ unless env
22
+ $stderr.puts "Namespace #{pattern.inspect} not found"
23
+ return
24
+ end
25
+
26
+ env.namespace_module.module_eval(&block)
27
+ end
28
+
29
+ def within_env(pattern, &block)
30
+ env = find_environment { |candidate| name_matches?(candidate.name, pattern) }
31
+ unless env
32
+ $stderr.puts "Environment #{pattern.inspect} not found"
33
+ return
34
+ end
35
+
36
+ env.namespace_module.module_eval(&block)
37
+ end
38
+
39
+ def env_config(regexp)
40
+ env = find_environment { |candidate| candidate.name.to_s =~ regexp }
41
+ env&.config
42
+ end
43
+
44
+ def q(doc)
45
+ environments = Monq::App.instance.environments
46
+ if environments.size != 1
47
+ $stderr.puts "use Namespace.q"
48
+ return nil
49
+ end
50
+
51
+ environments.first.client.database.command(doc)
52
+ end
53
+
54
+ private
55
+
56
+ def find_environment(&block)
57
+ Monq::App.instance.environments.find(&block)
58
+ end
59
+
60
+ def name_matches?(value, pattern)
61
+ case pattern
62
+ when Symbol, String
63
+ value.to_s == pattern.to_s
64
+ when Regexp
65
+ value.to_s =~ pattern
66
+ else
67
+ false
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Object
4
+ def self.const_missing(name)
5
+ app = Monq::App.instance if defined?(Monq::App)
6
+ if app&.environments
7
+ app.environments.each do |env|
8
+ mod = env.namespace_module
9
+ return mod.const_get(name) if mod.const_defined?(name, false)
10
+ end
11
+ end
12
+ super
13
+ end
14
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ module Ext
5
+ module View
6
+ def t
7
+ materialized_docs.t
8
+ end
9
+
10
+ def v
11
+ materialized_docs.v
12
+ end
13
+
14
+ def vd
15
+ materialized_docs.vd
16
+ end
17
+
18
+ # Full result set. Display methods cap at 500; export must not.
19
+ def write_csv(filename)
20
+ to_a.write_csv(filename)
21
+ end
22
+
23
+ def write_excel(filename, sheet_name: "Sheet1")
24
+ to_a.write_excel(filename, sheet_name: sheet_name)
25
+ end
26
+
27
+ private
28
+
29
+ def materialized_docs
30
+ view = self
31
+ if respond_to?(:options) && respond_to?(:limit) && Monq::Ext.limit_unset?(options)
32
+ view = limit(Monq::Ext::CAP)
33
+ end
34
+ view.to_a
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ Mongo::Collection::View.include(Monq::Ext::View)
data/lib/monq/ext.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ module Ext
5
+ CAP = 500
6
+
7
+ def self.materialize(criteria)
8
+ capped = limit_unset?(criteria.options)
9
+ criteria = criteria.limit(CAP) if capped
10
+ [criteria.to_a, capped]
11
+ end
12
+
13
+ def self.display_criteria(criteria)
14
+ docs, capped = materialize(criteria)
15
+ puts table_for(docs)
16
+ puts "showing #{docs.size} rows (capped at #{CAP})" if capped
17
+ nil
18
+ end
19
+
20
+ def self.table_for(docs)
21
+ rows = Presenter.rows(docs)
22
+ Table.new do |table|
23
+ table.headers = rows.first || []
24
+ table.body = rows[1..] || []
25
+ end.to_terminal
26
+ end
27
+
28
+ def self.vertical(docs)
29
+ rows = Presenter.rows(docs)
30
+ return [] if rows.empty?
31
+
32
+ keys = rows.first
33
+ [keys, nil, *rows[1..]]
34
+ end
35
+
36
+ def self.limit_unset?(options)
37
+ return true if options.nil?
38
+
39
+ options[:limit].nil? && options["limit"].nil?
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ class MultiIO
5
+ ANSI = /\e\[(\d+)(?:;\d+)*m/.freeze
6
+
7
+ def initialize(*targets)
8
+ @targets = targets
9
+ end
10
+
11
+ def write(*args)
12
+ @targets.each do |target|
13
+ if tty?(target)
14
+ target.write(*args)
15
+ else
16
+ target.write(*args.map { |str| strip_ansi(str) })
17
+ end
18
+ target.flush if target.respond_to?(:flush)
19
+ end
20
+ end
21
+
22
+ def <<(target)
23
+ @targets << target
24
+ self
25
+ end
26
+
27
+ def close
28
+ @targets.each { |target| target.close if target.respond_to?(:close) }
29
+ end
30
+
31
+ def delete(io)
32
+ @targets.delete(io)
33
+ end
34
+
35
+ def include?(io)
36
+ @targets.include?(io)
37
+ end
38
+
39
+ private
40
+
41
+ def tty?(target)
42
+ target.respond_to?(:isatty) ? target.isatty : false
43
+ end
44
+
45
+ def strip_ansi(value)
46
+ value.to_s.gsub(ANSI, "")
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "bson"
5
+
6
+ module Monq
7
+ module Presenter
8
+ module_function
9
+
10
+ def scalar(value)
11
+ case value
12
+ when BSON::ObjectId
13
+ value.to_s
14
+ when Time, Date
15
+ value.strftime("%Y-%m-%d %H:%M:%S")
16
+ when BSON::Binary
17
+ "#<BSON::Binary type=#{value.type} size=#{value.data.bytesize}>"
18
+ when BSON::Decimal128
19
+ value.to_s
20
+ when Hash, Array
21
+ json = JSON.generate(as_json_tree(value))
22
+ json.length > 120 ? "#{json[0, 120]}…" : json
23
+ else
24
+ value.respond_to?(:as_json) ? value.as_json : value.to_s
25
+ end
26
+ end
27
+
28
+ def rows(docs)
29
+ return [] if docs.nil? || docs.empty?
30
+
31
+ keys = docs.first.keys
32
+ body = docs.map { |doc| keys.map { |key| scalar(doc[key]) } }
33
+ [keys, *body]
34
+ end
35
+
36
+ def as_json_tree(value)
37
+ case value
38
+ when BSON::ObjectId, BSON::Decimal128
39
+ value.to_s
40
+ when BSON::Binary
41
+ "#<BSON::Binary type=#{value.type} size=#{value.data.bytesize}>"
42
+ when Time, Date
43
+ value.strftime("%Y-%m-%d %H:%M:%S")
44
+ when Hash
45
+ value.each_with_object({}) { |(key, item), hash| hash[key] = as_json_tree(item) }
46
+ when Array
47
+ value.map { |item| as_json_tree(item) }
48
+ else
49
+ value.respond_to?(:as_json) ? value.as_json : value.to_s
50
+ end
51
+ end
52
+ private_class_method :as_json_tree
53
+ end
54
+ end
data/lib/monq/repl.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pry"
4
+ require "rainbow"
5
+ require_relative "commands"
6
+
7
+ module Monq
8
+ class Repl
9
+ def start
10
+ Commands.register_pry_commands!
11
+ unless App.instance.options.babel_compatable
12
+ Pry.config.prompt = Pry::Prompt.new("monq", "monq", prompt)
13
+ end
14
+ Pry.start
15
+ end
16
+
17
+ def prompt
18
+ [proc do |obj, nest_level, pry_instance|
19
+ names = App.instance.environments.map(&:name).join("+")
20
+ "%s#{Rainbow('@').green}%s%s%s [%d] %s " % [
21
+ Rainbow("MONQ").red,
22
+ Rainbow(names).yellow,
23
+ sandbox_indicator,
24
+ context_prompt(obj, nest_level),
25
+ pry_instance.input_ring.count,
26
+ Rainbow("❯").green
27
+ ]
28
+ end]
29
+ end
30
+
31
+ def context_prompt(obj, nest_level)
32
+ if nest_level.zero?
33
+ "(#{obj})"
34
+ else
35
+ "(#{obj}:#{nest_level})"
36
+ end
37
+ end
38
+
39
+ def sandbox_indicator
40
+ return "" unless App.instance.environments.any?(&:sandbox?)
41
+
42
+ " [%s] " % Rainbow("sandbox").green.bright
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/ssh/gateway"
4
+
5
+ module Monq
6
+ class SSHProxy
7
+ attr_reader :local_port
8
+
9
+ def initialize(host:, user:, forward_host:, forward_port:, port: 22, password: nil, local_port: nil)
10
+ @host = host
11
+ @user = user
12
+ @forward_host = forward_host
13
+ @forward_port = forward_port
14
+ @opts = { port: port }
15
+ @opts[:password] = password if password
16
+ open_gateway!(local_port)
17
+ end
18
+
19
+ def database_host_port
20
+ { host: "127.0.0.1", port: @local_port }
21
+ end
22
+
23
+ def active?
24
+ @gateway.active?
25
+ end
26
+
27
+ def reconnect
28
+ reconnect! unless active?
29
+ end
30
+
31
+ def reconnect!
32
+ @gateway.shutdown!
33
+ open_gateway!(@local_port)
34
+ end
35
+
36
+ private
37
+
38
+ def open_gateway!(local_port)
39
+ @gateway = Net::SSH::Gateway.new(@host, @user, @opts)
40
+ @local_port = @gateway.open(@forward_host, @forward_port, local_port)
41
+ end
42
+ end
43
+ end