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.
- checksums.yaml +7 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -0
- data/Rakefile +8 -0
- data/docs/superpowers/plans/2026-10-24-monq.md +1384 -0
- data/docs/superpowers/specs/2026-10-24-monq-design.md +401 -0
- data/exe/monq +6 -0
- data/lib/monq/app.rb +86 -0
- data/lib/monq/cli.rb +141 -0
- data/lib/monq/commands/info.rb +45 -0
- data/lib/monq/commands/models.rb +95 -0
- data/lib/monq/commands/reconnect.rb +45 -0
- data/lib/monq/commands/redefine.rb +25 -0
- data/lib/monq/commands/sandbox.rb +57 -0
- data/lib/monq/commands/show_sql.rb +92 -0
- data/lib/monq/commands.rb +25 -0
- data/lib/monq/config.rb +216 -0
- data/lib/monq/environment.rb +218 -0
- data/lib/monq/errors.rb +5 -0
- data/lib/monq/ext/array.rb +57 -0
- data/lib/monq/ext/criteria.rb +31 -0
- data/lib/monq/ext/document.rb +29 -0
- data/lib/monq/ext/kernel.rb +70 -0
- data/lib/monq/ext/object.rb +14 -0
- data/lib/monq/ext/view.rb +40 -0
- data/lib/monq/ext.rb +42 -0
- data/lib/monq/multi_io.rb +49 -0
- data/lib/monq/presenter.rb +54 -0
- data/lib/monq/repl.rb +45 -0
- data/lib/monq/ssh_proxy.rb +43 -0
- data/lib/monq/table.rb +50 -0
- data/lib/monq/version.rb +5 -0
- data/lib/monq.rb +23 -0
- data/sig/monq.rbs +4 -0
- metadata +193 -0
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
# Monq Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a Pry workbench that loads user-authored Mongoid models from `~/.monq.d` and queries MongoDB the way Arql queries SQL.
|
|
6
|
+
|
|
7
|
+
**Architecture:** `Cli` parses options, `App` merges YAML and owns environments, `Environment` owns one Mongoid client plus one namespace. Display (`t`/`v`/`vd`) and Pry commands sit on top and never open connections themselves.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby >= 3.2, Mongoid ~> 9, mongo driver, Pry, net-ssh-gateway, terminal-table, rainbow, caxlsx, roo, RSpec.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-10-24-monq-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File map
|
|
16
|
+
|
|
17
|
+
| File | Responsibility |
|
|
18
|
+
|------|----------------|
|
|
19
|
+
| `lib/monq/errors.rb` | `Monq::Error` exit messages |
|
|
20
|
+
| `lib/monq/config.rb` | YAML load, env selection, CLI merge, client option build |
|
|
21
|
+
| `lib/monq/cli.rb` | OptionParser → OpenStruct |
|
|
22
|
+
| `lib/monq/app.rb` | Singleton, connect, load init.rb, `run!` |
|
|
23
|
+
| `lib/monq/environment.rb` | Client, namespace, models, reconnect, sandbox session |
|
|
24
|
+
| `lib/monq/ssh_proxy.rb` | Local port forward |
|
|
25
|
+
| `lib/monq/presenter.rb` | BSON → printable |
|
|
26
|
+
| `lib/monq/table.rb` `vd.rb` `multi_io.rb` | Tables, visidata, log fan-out |
|
|
27
|
+
| `lib/monq/repl.rb` | Pry prompt |
|
|
28
|
+
| `lib/monq/commands/*.rb` | Pry commands |
|
|
29
|
+
| `lib/monq/ext/*.rb` | Kernel, const_missing, document/criteria/view/array |
|
|
30
|
+
| `exe/monq` | Executable |
|
|
31
|
+
| `spec/` | Unit tests; integration gated by env |
|
|
32
|
+
|
|
33
|
+
Working directory for every command: `/Users/liuxiang/cascode/github.com/monq`.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
### Task 1: Gem skeleton and error class
|
|
38
|
+
|
|
39
|
+
**Files:**
|
|
40
|
+
- Modify: `monq.gemspec`
|
|
41
|
+
- Modify: `lib/monq.rb`
|
|
42
|
+
- Create: `lib/monq/errors.rb`
|
|
43
|
+
- Create: `exe/monq`
|
|
44
|
+
- Modify: `spec/monq_spec.rb`
|
|
45
|
+
|
|
46
|
+
- [ ] **Step 1: Write the failing test**
|
|
47
|
+
|
|
48
|
+
Replace `spec/monq_spec.rb` with:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# frozen_string_literal: true
|
|
52
|
+
|
|
53
|
+
RSpec.describe Monq do
|
|
54
|
+
it "has a version" do
|
|
55
|
+
expect(Monq::VERSION).to match(/\A\d+\.\d+\.\d+\z/)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "defines Error" do
|
|
59
|
+
expect(Monq::Error.new("nope").message).to eq("nope")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
65
|
+
|
|
66
|
+
Run: `bundle exec rspec spec/monq_spec.rb`
|
|
67
|
+
Expected: FAIL, `uninitialized constant Monq::Error`
|
|
68
|
+
|
|
69
|
+
- [ ] **Step 3: Implement**
|
|
70
|
+
|
|
71
|
+
`lib/monq/errors.rb`:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# frozen_string_literal: true
|
|
75
|
+
|
|
76
|
+
module Monq
|
|
77
|
+
class Error < StandardError; end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`lib/monq.rb`:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# frozen_string_literal: true
|
|
85
|
+
|
|
86
|
+
require_relative "monq/version"
|
|
87
|
+
require_relative "monq/errors"
|
|
88
|
+
|
|
89
|
+
module Monq
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`exe/monq`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
#!/usr/bin/env ruby
|
|
97
|
+
# frozen_string_literal: true
|
|
98
|
+
|
|
99
|
+
require "monq"
|
|
100
|
+
|
|
101
|
+
Monq::Cli.start
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`chmod +x exe/monq`.
|
|
105
|
+
|
|
106
|
+
In `monq.gemspec`, replace the TODO summary/description:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
spec.summary = "Mongoid + Pry MongoDB query workbench"
|
|
110
|
+
spec.description = "Load user-authored Mongoid models and query MongoDB from a Pry REPL."
|
|
111
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Add runtime deps:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
spec.add_dependency "mongoid", "~> 9.0"
|
|
118
|
+
spec.add_dependency "pry", "~> 0.15"
|
|
119
|
+
spec.add_dependency "pry-byebug", "~> 3.10"
|
|
120
|
+
spec.add_dependency "net-ssh-gateway", "~> 2.0"
|
|
121
|
+
spec.add_dependency "terminal-table", "~> 4.0"
|
|
122
|
+
spec.add_dependency "rainbow", "~> 3.1"
|
|
123
|
+
spec.add_dependency "caxlsx", "~> 4.1"
|
|
124
|
+
spec.add_dependency "roo", "~> 2.10"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Run `bundle install`.
|
|
128
|
+
|
|
129
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
130
|
+
|
|
131
|
+
Run: `bundle exec rspec spec/monq_spec.rb`
|
|
132
|
+
Expected: 2 examples, 0 failures
|
|
133
|
+
|
|
134
|
+
- [ ] **Step 5: Commit**
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
git add monq.gemspec Gemfile.lock lib/monq.rb lib/monq/errors.rb exe/monq spec/monq_spec.rb
|
|
138
|
+
git commit -m "feat: add monq executable skeleton and Error"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### Task 2: Config — select environments
|
|
144
|
+
|
|
145
|
+
**Files:**
|
|
146
|
+
- Create: `lib/monq/config.rb`
|
|
147
|
+
- Create: `spec/config_spec.rb`
|
|
148
|
+
|
|
149
|
+
- [ ] **Step 1: Write the failing test**
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
# frozen_string_literal: true
|
|
153
|
+
|
|
154
|
+
require "ostruct"
|
|
155
|
+
|
|
156
|
+
RSpec.describe Monq::Config do
|
|
157
|
+
let(:yaml) do
|
|
158
|
+
{
|
|
159
|
+
"default" => { "auth_source" => "admin" },
|
|
160
|
+
"blog.dev" => { "uri" => "mongodb://h/db", "database" => "blog_dev", "namespace" => "Blog" },
|
|
161
|
+
"ops.prod" => { "host" => "ops.example.com", "database" => "ops" }
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "rejects reserved environment names" do
|
|
166
|
+
expect { described_class.select_environments(yaml, ["default"], OpenStruct.new) }
|
|
167
|
+
.to raise_error(Monq::Error, /reserved/i)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "fails when an -e name is missing" do
|
|
171
|
+
expect { described_class.select_environments(yaml, ["nope"], OpenStruct.new) }
|
|
172
|
+
.to raise_error(Monq::Error, /nope/)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "does not treat the YAML default anchor as an environment when -e is absent" do
|
|
176
|
+
expect { described_class.select_environments(yaml, nil, OpenStruct.new) }
|
|
177
|
+
.to raise_error(Monq::Error, /-e/)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "slices only requested environments" do
|
|
181
|
+
envs = described_class.select_environments(yaml, ["blog.dev"], OpenStruct.new)
|
|
182
|
+
expect(envs.keys).to eq(["blog.dev"])
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "builds a synthetic default from CLI when -e is absent but uri is present" do
|
|
186
|
+
opts = OpenStruct.new(uri: "mongodb://h/x", database: "x")
|
|
187
|
+
envs = described_class.select_environments({}, nil, opts)
|
|
188
|
+
expect(envs.keys).to eq(["default"])
|
|
189
|
+
expect(envs["default"][:uri]).to eq("mongodb://h/x")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "rejects two environments that share a namespace" do
|
|
193
|
+
yaml["ops.prod"]["namespace"] = "Blog"
|
|
194
|
+
expect { described_class.select_environments(yaml, ["blog.dev", "ops.prod"], OpenStruct.new) }
|
|
195
|
+
.to raise_error(Monq::Error, /namespace/i)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
201
|
+
|
|
202
|
+
Run: `bundle exec rspec spec/config_spec.rb`
|
|
203
|
+
Expected: FAIL, `uninitialized constant Monq::Config`
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 3: Implement**
|
|
206
|
+
|
|
207
|
+
`lib/monq/config.rb`:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# frozen_string_literal: true
|
|
211
|
+
|
|
212
|
+
require "yaml"
|
|
213
|
+
require "ostruct"
|
|
214
|
+
|
|
215
|
+
module Monq
|
|
216
|
+
module Config
|
|
217
|
+
RESERVED = %w[default arql].freeze
|
|
218
|
+
|
|
219
|
+
module_function
|
|
220
|
+
|
|
221
|
+
def load_file(path)
|
|
222
|
+
raw = YAML.safe_load(File.read(path), aliases: true)
|
|
223
|
+
(raw || {}).transform_keys(&:to_s)
|
|
224
|
+
rescue ArgumentError
|
|
225
|
+
raw = YAML.safe_load(File.read(path))
|
|
226
|
+
(raw || {}).transform_keys(&:to_s)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def select_environments(yaml, names, options)
|
|
230
|
+
yaml = stringify(yaml)
|
|
231
|
+
if names.nil? || names.empty?
|
|
232
|
+
return { "default" => cli_env(options) } if sufficient_cli?(options)
|
|
233
|
+
|
|
234
|
+
raise Error, "No -e given and CLI has no connection. Pass -e or --uri, or create ~/.monq.d/init.yml"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
names.each do |name|
|
|
238
|
+
raise Error, "#{name} is a reserved environment name" if RESERVED.include?(name)
|
|
239
|
+
raise Error, "Environment #{name} not in config. Known: #{known(yaml).join(', ')}" unless yaml.key?(name)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
selected = names.to_h { |n| [n, deep_sym(yaml[n])] }
|
|
243
|
+
apply_cli!(selected, options)
|
|
244
|
+
assign_namespaces!(selected)
|
|
245
|
+
assert_unique_namespaces!(selected)
|
|
246
|
+
selected
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def known(yaml)
|
|
250
|
+
yaml.keys - RESERVED
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def sufficient_cli?(options)
|
|
254
|
+
options.uri.to_s != "" || (options.host.to_s != "" && options.database.to_s != "")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def cli_env(options)
|
|
258
|
+
{
|
|
259
|
+
uri: options.uri,
|
|
260
|
+
host: options.host,
|
|
261
|
+
port: options.port,
|
|
262
|
+
database: options.database,
|
|
263
|
+
username: options.username,
|
|
264
|
+
password: options.password,
|
|
265
|
+
auth_source: options.auth_source
|
|
266
|
+
}.compact
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def apply_cli!(selected, options)
|
|
270
|
+
overrides = connection_overrides(options)
|
|
271
|
+
if selected.size > 1 && overrides.any?
|
|
272
|
+
raise Error, "Connection CLI flags are not allowed with multiple -e environments"
|
|
273
|
+
end
|
|
274
|
+
return if selected.size != 1
|
|
275
|
+
|
|
276
|
+
env = selected.values.first
|
|
277
|
+
overrides.each { |k, v| env[k] = v }
|
|
278
|
+
merge_ssh!(env, options)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def connection_overrides(options)
|
|
282
|
+
{
|
|
283
|
+
uri: options.uri,
|
|
284
|
+
host: options.host,
|
|
285
|
+
port: options.port,
|
|
286
|
+
database: options.database,
|
|
287
|
+
username: options.username,
|
|
288
|
+
password: options.password,
|
|
289
|
+
auth_source: options.auth_source
|
|
290
|
+
}.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def merge_ssh!(env, options)
|
|
294
|
+
return if options.ssh.nil? || options.ssh.empty?
|
|
295
|
+
|
|
296
|
+
env[:ssh] = (env[:ssh] || {}).merge(options.ssh.transform_keys(&:to_sym))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def assign_namespaces!(selected)
|
|
300
|
+
selected.each do |name, env|
|
|
301
|
+
env[:namespace] ||= camelize(name)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def assert_unique_namespaces!(selected)
|
|
306
|
+
groups = selected.group_by { |_, env| env[:namespace] }
|
|
307
|
+
dup = groups.find { |_, pairs| pairs.size > 1 }
|
|
308
|
+
return unless dup
|
|
309
|
+
|
|
310
|
+
raise Error, "Environments share namespace #{dup.first}: #{dup.last.map(&:first).join(', ')}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def camelize(name)
|
|
314
|
+
name.split(/[^A-Za-z0-9]+/).reject(&:empty?).map { |p| p[0].upcase + p[1..] }.join
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def stringify(hash)
|
|
318
|
+
hash.to_h.transform_keys(&:to_s).transform_values { |v| v.is_a?(Hash) ? stringify(v) : v }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def deep_sym(obj)
|
|
322
|
+
case obj
|
|
323
|
+
when Hash then obj.to_h { |k, v| [k.to_sym, deep_sym(v)] }
|
|
324
|
+
else obj
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def prefix_for(env_name)
|
|
329
|
+
env_name.split(/[._-]/, 2).first
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def client_name_for(env_name)
|
|
333
|
+
env_name.gsub(/[^A-Za-z0-9]/, "_").to_sym
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`require_relative "monq/config"` in `lib/monq.rb`.
|
|
340
|
+
|
|
341
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
342
|
+
|
|
343
|
+
Run: `bundle exec rspec spec/config_spec.rb`
|
|
344
|
+
Expected: 6 examples, 0 failures
|
|
345
|
+
|
|
346
|
+
- [ ] **Step 5: Commit**
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
git add lib/monq.rb lib/monq/config.rb spec/config_spec.rb
|
|
350
|
+
git commit -m "feat: select monq environments from YAML and CLI"
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
### Task 3: Config — client options, URI mask, SSH rewrite
|
|
356
|
+
|
|
357
|
+
**Files:**
|
|
358
|
+
- Modify: `lib/monq/config.rb`
|
|
359
|
+
- Modify: `spec/config_spec.rb`
|
|
360
|
+
|
|
361
|
+
- [ ] **Step 1: Write the failing test**
|
|
362
|
+
|
|
363
|
+
Append to `spec/config_spec.rb`:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
RSpec.describe Monq::Config do
|
|
367
|
+
describe ".client_options" do
|
|
368
|
+
it "prefers uri and lets database override the URI path" do
|
|
369
|
+
opts = described_class.client_options(uri: "mongodb://u:p@db.example.com:27017/other?authSource=admin", database: "blog_dev")
|
|
370
|
+
expect(opts[:database]).to eq("blog_dev")
|
|
371
|
+
expect(opts[:hosts]).to eq(["db.example.com:27017"])
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
it "requires database when the URI has no path" do
|
|
375
|
+
expect { described_class.client_options(uri: "mongodb://db.example.com:27017/?authSource=admin") }
|
|
376
|
+
.to raise_error(Monq::Error, /database/)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
it "builds hosts from split fields when uri is absent" do
|
|
380
|
+
opts = described_class.client_options(host: "mongo.example.com", database: "x")
|
|
381
|
+
expect(opts[:hosts]).to eq(["mongo.example.com:27017"])
|
|
382
|
+
expect(opts[:database]).to eq("x")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
it "rewrites ssh to one direct seed and drops replica_set" do
|
|
386
|
+
opts = described_class.client_options(
|
|
387
|
+
uri: "mongodb://u:p@db.example.com:27017,db2.example.com:27017/blog_dev?replicaSet=rs0",
|
|
388
|
+
ssh: { local_host: "127.0.0.1", local_port: 27018 }
|
|
389
|
+
)
|
|
390
|
+
expect(opts[:hosts]).to eq(["127.0.0.1:27018"])
|
|
391
|
+
expect(opts[:options][:direct_connection]).to eq(true)
|
|
392
|
+
expect(opts[:options]).not_to have_key(:replica_set)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
it "rejects mongodb+srv together with ssh" do
|
|
396
|
+
expect {
|
|
397
|
+
described_class.client_options(uri: "mongodb+srv://u:p@cluster.example/?authSource=admin", database: "x", ssh: { local_port: 1 })
|
|
398
|
+
}.to raise_error(Monq::Error, /srv/i)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
describe ".mask_uri" do
|
|
403
|
+
it "masks the password" do
|
|
404
|
+
expect(described_class.mask_uri("mongodb://admin:secret@db.example.com:27017/db"))
|
|
405
|
+
.to eq("mongodb://admin:***@db.example.com:27017/db")
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
it "maps env name to prefix and client name" do
|
|
410
|
+
expect(described_class.prefix_for("blog.dev")).to eq("blog")
|
|
411
|
+
expect(described_class.client_name_for("blog.dev")).to eq(:blog_dev)
|
|
412
|
+
expect(described_class.camelize("blog.dev")).to eq("BlogDev")
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
418
|
+
|
|
419
|
+
Run: `bundle exec rspec spec/config_spec.rb`
|
|
420
|
+
Expected: FAIL, `client_options` missing
|
|
421
|
+
|
|
422
|
+
- [ ] **Step 3: Implement**
|
|
423
|
+
|
|
424
|
+
Add to `Monq::Config` (still `module_function`):
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
def client_options(env)
|
|
428
|
+
env = deep_sym(env)
|
|
429
|
+
ssh = env[:ssh]
|
|
430
|
+
if ssh && env[:uri].to_s.start_with?("mongodb+srv:")
|
|
431
|
+
raise Error, "mongodb+srv with ssh is not supported; use a direct host"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
built = env[:uri].to_s != "" ? from_uri(env) : from_fields(env)
|
|
435
|
+
return built unless ssh && ssh[:local_port]
|
|
436
|
+
|
|
437
|
+
{
|
|
438
|
+
hosts: ["127.0.0.1:#{ssh[:local_port]}"],
|
|
439
|
+
database: built[:database],
|
|
440
|
+
options: built[:options].merge(direct_connection: true).except(:replica_set, :connect)
|
|
441
|
+
}
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def from_uri(env)
|
|
445
|
+
require "mongo"
|
|
446
|
+
parsed = Mongo::URI.get(env[:uri])
|
|
447
|
+
database = env[:database].to_s
|
|
448
|
+
database = parsed.database if database.empty?
|
|
449
|
+
raise Error, "database is required (URI path is empty)" if database.nil? || database.empty?
|
|
450
|
+
|
|
451
|
+
options = parsed.uri_options.dup
|
|
452
|
+
options.delete(:replica_set) if env[:ssh] && env[:ssh][:local_port]
|
|
453
|
+
{
|
|
454
|
+
hosts: parsed.servers,
|
|
455
|
+
database: database,
|
|
456
|
+
options: options
|
|
457
|
+
}
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def from_fields(env)
|
|
461
|
+
raise Error, "host and database are required" if env[:host].to_s.empty? || env[:database].to_s.empty?
|
|
462
|
+
|
|
463
|
+
port = env[:port] || 27017
|
|
464
|
+
options = {}
|
|
465
|
+
options[:user] = env[:username] if env[:username]
|
|
466
|
+
options[:password] = env[:password] if env[:password]
|
|
467
|
+
options[:auth_source] = env[:auth_source] if env[:auth_source]
|
|
468
|
+
options[:replica_set] = env[:replica_set] if env[:replica_set] && !(env[:ssh] && env[:ssh][:local_port])
|
|
469
|
+
options[:ssl] = env[:ssl] unless env[:ssl].nil?
|
|
470
|
+
{ hosts: ["#{env[:host]}:#{port}"], database: env[:database], options: options }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def mask_uri(uri)
|
|
474
|
+
uri.to_s.sub(%r{://([^:/@]+):[^@]+@}, '://\1:***@')
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def ssh_forward_target(env)
|
|
478
|
+
env = deep_sym(env)
|
|
479
|
+
if env[:uri].to_s != ""
|
|
480
|
+
require "mongo"
|
|
481
|
+
host, port = Mongo::URI.get(env[:uri]).servers.first.split(":")
|
|
482
|
+
{ host: host, port: (port || 27017).to_i }
|
|
483
|
+
else
|
|
484
|
+
{ host: env[:host], port: (env[:port] || 27017).to_i }
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
If `Mongo::URI.get` does not exist in the installed driver, use `Mongo::URI.new(env[:uri])` and read `#servers` / `#database` / `#options` — adjust the three method names to whatever `bundle exec ruby -rmongo -e 'p Mongo::URI.instance_methods(false)'` prints, keeping the spec expectations above.
|
|
490
|
+
|
|
491
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
492
|
+
|
|
493
|
+
Run: `bundle exec rspec spec/config_spec.rb`
|
|
494
|
+
Expected: all examples pass
|
|
495
|
+
|
|
496
|
+
- [ ] **Step 5: Commit**
|
|
497
|
+
|
|
498
|
+
```bash
|
|
499
|
+
git add lib/monq/config.rb spec/config_spec.rb
|
|
500
|
+
git commit -m "feat: build Mongo client options and mask URIs"
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
### Task 4: Environment connects a named client without models
|
|
506
|
+
|
|
507
|
+
**Files:**
|
|
508
|
+
- Create: `lib/monq/environment.rb`
|
|
509
|
+
- Create: `spec/environment_spec.rb`
|
|
510
|
+
|
|
511
|
+
Use a fake client so unit tests do not dial the network.
|
|
512
|
+
|
|
513
|
+
- [ ] **Step 1: Write the failing test**
|
|
514
|
+
|
|
515
|
+
```ruby
|
|
516
|
+
# frozen_string_literal: true
|
|
517
|
+
|
|
518
|
+
RSpec.describe Monq::Environment do
|
|
519
|
+
let(:hello_doc) { { "ok" => 1, "setName" => "rs0" } }
|
|
520
|
+
let(:fake_client) do
|
|
521
|
+
db = double("db", command: double(documents: [hello_doc]), collection_names: ["users"])
|
|
522
|
+
instance_double("Mongo::Client", database: db, close: nil)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
before do
|
|
526
|
+
allow(Mongo::Client).to receive(:new).and_return(fake_client)
|
|
527
|
+
allow(Mongoid::Clients).to receive(:set)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
it "registers the named client and maps the first env to :default" do
|
|
531
|
+
env = described_class.new("blog.dev", { uri: "mongodb://h/blog_dev", database: "blog_dev", namespace: "Blog" }, first: true, model_dir: "/tmp")
|
|
532
|
+
env.connect!
|
|
533
|
+
expect(Mongoid::Clients).to have_received(:set).with(:blog_dev, fake_client)
|
|
534
|
+
expect(Mongoid::Clients).to have_received(:set).with(:default, fake_client)
|
|
535
|
+
expect(env.namespace).to eq("Blog")
|
|
536
|
+
expect(env.ping?).to eq(true)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
it "does not map later environments onto :default" do
|
|
540
|
+
env = described_class.new("ops.prod", { host: "h", database: "ops", namespace: "Ops" }, first: false, model_dir: "/tmp")
|
|
541
|
+
env.connect!
|
|
542
|
+
expect(Mongoid::Clients).to have_received(:set).with(:ops_prod, fake_client)
|
|
543
|
+
expect(Mongoid::Clients).not_to have_received(:set).with(:default, fake_client)
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
549
|
+
|
|
550
|
+
Run: `bundle exec rspec spec/environment_spec.rb`
|
|
551
|
+
Expected: FAIL, `uninitialized constant Monq::Environment`
|
|
552
|
+
|
|
553
|
+
- [ ] **Step 3: Implement**
|
|
554
|
+
|
|
555
|
+
`lib/monq/environment.rb`:
|
|
556
|
+
|
|
557
|
+
```ruby
|
|
558
|
+
# frozen_string_literal: true
|
|
559
|
+
|
|
560
|
+
require "mongoid"
|
|
561
|
+
|
|
562
|
+
module Monq
|
|
563
|
+
class Environment
|
|
564
|
+
attr_reader :name, :config, :client_name, :namespace, :client, :ssh_proxy
|
|
565
|
+
|
|
566
|
+
def initialize(name, config, first:, model_dir:)
|
|
567
|
+
@name = name
|
|
568
|
+
@config = config
|
|
569
|
+
@first = first
|
|
570
|
+
@model_dir = model_dir
|
|
571
|
+
@client_name = Config.client_name_for(name)
|
|
572
|
+
@namespace = config[:namespace]
|
|
573
|
+
@models = []
|
|
574
|
+
@sandbox_session = nil
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def connect!
|
|
578
|
+
opts = Config.client_options(config)
|
|
579
|
+
@client = Mongo::Client.new(opts[:hosts], **opts[:options].merge(database: opts[:database]))
|
|
580
|
+
Mongoid::Clients.set(client_name, @client)
|
|
581
|
+
Mongoid::Clients.set(:default, @client) if @first
|
|
582
|
+
hello!
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def hello!
|
|
586
|
+
@client.database.command(hello: 1).documents.first
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def ping?
|
|
590
|
+
hello!
|
|
591
|
+
true
|
|
592
|
+
rescue StandardError
|
|
593
|
+
false
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def reconnect!
|
|
597
|
+
raise Error, "sandbox is active; run sandbox-quit first" if sandbox?
|
|
598
|
+
|
|
599
|
+
@client&.close
|
|
600
|
+
connect!
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def namespace_module
|
|
604
|
+
@namespace_module ||= Object.const_set(namespace, Module.new)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def sandbox?
|
|
608
|
+
!@sandbox_session.nil?
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
`require_relative "monq/environment"` in `lib/monq.rb`.
|
|
615
|
+
|
|
616
|
+
If `Mongoid::Clients.set` is not public in the installed version, check `Mongoid::Clients.methods` and use the method that stores a client under a symbol (often `set` or `clients[name] =`). Keep the spec's `have_received(:set)` by wrapping whatever the gem actually exposes in `Environment#register_client`.
|
|
617
|
+
|
|
618
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
619
|
+
|
|
620
|
+
Run: `bundle exec rspec spec/environment_spec.rb`
|
|
621
|
+
Expected: 2 examples, 0 failures
|
|
622
|
+
|
|
623
|
+
- [ ] **Step 5: Commit**
|
|
624
|
+
|
|
625
|
+
```bash
|
|
626
|
+
git add lib/monq.rb lib/monq/environment.rb spec/environment_spec.rb
|
|
627
|
+
git commit -m "feat: connect a named Mongoid client per environment"
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
### Task 5: Load models into the namespace and bind clients
|
|
633
|
+
|
|
634
|
+
**Files:**
|
|
635
|
+
- Modify: `lib/monq/environment.rb`
|
|
636
|
+
- Modify: `spec/environment_spec.rb`
|
|
637
|
+
|
|
638
|
+
- [ ] **Step 1: Write the failing test**
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
RSpec.describe Monq::Environment do
|
|
642
|
+
it "loads prefix file into the namespace and rebinds :default client" do
|
|
643
|
+
dir = Dir.mktmpdir
|
|
644
|
+
File.write(File.join(dir, "blog.rb"), <<~RUBY)
|
|
645
|
+
class User
|
|
646
|
+
include Mongoid::Document
|
|
647
|
+
store_in collection: "users"
|
|
648
|
+
embeds_one :meta, class_name: "UserMeta"
|
|
649
|
+
end
|
|
650
|
+
class UserMeta
|
|
651
|
+
include Mongoid::Document
|
|
652
|
+
embedded_in :user
|
|
653
|
+
field :status, type: String
|
|
654
|
+
end
|
|
655
|
+
RUBY
|
|
656
|
+
|
|
657
|
+
env = described_class.new("blog.dev", { host: "h", database: "d", namespace: "Blog" }, first: true, model_dir: dir)
|
|
658
|
+
allow(env).to receive(:connect!)
|
|
659
|
+
env.load_models!
|
|
660
|
+
expect(Blog::User.storage_options[:client]).to eq(:blog_dev)
|
|
661
|
+
expect(Blog::User.storage_options[:collection]).to eq("users")
|
|
662
|
+
expect(Blog::UserMeta).to be_embedded
|
|
663
|
+
expect(Blog.const_defined?(:U)).to eq(true)
|
|
664
|
+
ensure
|
|
665
|
+
Object.send(:remove_const, :Blog) if Object.const_defined?(:Blog)
|
|
666
|
+
FileUtils.remove_entry(dir) if dir
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
it "warns and continues when the prefix file is missing" do
|
|
670
|
+
env = described_class.new("blog.dev", { host: "h", database: "d", namespace: "BlogX" }, first: true, model_dir: Dir.mktmpdir)
|
|
671
|
+
expect { env.load_models! }.to output(/blog\.rb/).to_stderr
|
|
672
|
+
expect(env.models).to eq([])
|
|
673
|
+
ensure
|
|
674
|
+
Object.send(:remove_const, :BlogX) if Object.const_defined?(:BlogX)
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
680
|
+
|
|
681
|
+
Run: `bundle exec rspec spec/environment_spec.rb`
|
|
682
|
+
Expected: FAIL, `load_models!` missing
|
|
683
|
+
|
|
684
|
+
- [ ] **Step 3: Implement**
|
|
685
|
+
|
|
686
|
+
Add to `Environment`:
|
|
687
|
+
|
|
688
|
+
```ruby
|
|
689
|
+
def load_models!
|
|
690
|
+
install_namespace_api!
|
|
691
|
+
path = File.join(@model_dir, "#{Config.prefix_for(name)}.rb")
|
|
692
|
+
unless File.file?(path)
|
|
693
|
+
warn "model file not found: #{path}"
|
|
694
|
+
return
|
|
695
|
+
end
|
|
696
|
+
namespace_module.module_eval(File.read(path), path)
|
|
697
|
+
bind_clients!
|
|
698
|
+
install_abbreviations!
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def models
|
|
702
|
+
namespace_module.constants.filter_map do |const_name|
|
|
703
|
+
klass = namespace_module.const_get(const_name)
|
|
704
|
+
klass if klass.is_a?(Class) && klass < Mongoid::Document && !klass.embedded?
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def redefine!
|
|
709
|
+
raise Error, "sandbox is active; run sandbox-quit first" if sandbox?
|
|
710
|
+
|
|
711
|
+
tracked_classes.each do |klass|
|
|
712
|
+
Mongoid.models.delete(klass) if Mongoid.models.respond_to?(:delete)
|
|
713
|
+
end
|
|
714
|
+
tracked_names.each { |n| namespace_module.send(:remove_const, n) if namespace_module.const_defined?(n, false) }
|
|
715
|
+
@tracked = []
|
|
716
|
+
load_models!
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def tracked_names
|
|
720
|
+
@tracked ||= []
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def tracked_classes
|
|
724
|
+
tracked_names.filter_map do |n|
|
|
725
|
+
namespace_module.const_get(n) if namespace_module.const_defined?(n, false)
|
|
726
|
+
end.select { |k| k.is_a?(Class) }
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def bind_clients!
|
|
730
|
+
namespace_module.constants.each do |const_name|
|
|
731
|
+
klass = namespace_module.const_get(const_name)
|
|
732
|
+
next unless klass.is_a?(Class) && klass < Mongoid::Document
|
|
733
|
+
next if klass.embedded?
|
|
734
|
+
|
|
735
|
+
current = klass.storage_options[:client]
|
|
736
|
+
next if current && current.to_sym != :default
|
|
737
|
+
|
|
738
|
+
klass.store_in klass.storage_options.merge(client: client_name)
|
|
739
|
+
tracked_names << const_name
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def install_abbreviations!
|
|
744
|
+
models.each do |klass|
|
|
745
|
+
abbr = klass.name.split("::").last.gsub(/[^A-Z]/, "")
|
|
746
|
+
abbr = "M" if abbr.empty?
|
|
747
|
+
candidate = abbr
|
|
748
|
+
n = 2
|
|
749
|
+
while namespace_module.const_defined?(candidate, false)
|
|
750
|
+
candidate = "#{abbr}#{n}"
|
|
751
|
+
n += 1
|
|
752
|
+
end
|
|
753
|
+
namespace_module.const_set(candidate, klass)
|
|
754
|
+
tracked_names << candidate.to_sym
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def install_namespace_api!
|
|
759
|
+
env = self
|
|
760
|
+
mod = namespace_module
|
|
761
|
+
mod.define_singleton_method(:client) { env.client }
|
|
762
|
+
mod.define_singleton_method(:models) { env.models }
|
|
763
|
+
mod.define_singleton_method(:collections) { env.client.database.collection_names }
|
|
764
|
+
mod.define_singleton_method(:config) { env.config }
|
|
765
|
+
mod.define_singleton_method(:q) { |doc| env.client.database.command(doc) }
|
|
766
|
+
end
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
`redefine!` must also record embedded classes created in the file so they are removed from `Mongoid.models`. After `module_eval`, walk `models` and each `embedded_relations` target class and push those class names into `tracked_names`.
|
|
770
|
+
|
|
771
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
772
|
+
|
|
773
|
+
Run: `bundle exec rspec spec/environment_spec.rb`
|
|
774
|
+
Expected: pass, including `Blog::User.storage_options[:collection] == "users"`
|
|
775
|
+
|
|
776
|
+
- [ ] **Step 5: Commit**
|
|
777
|
+
|
|
778
|
+
```bash
|
|
779
|
+
git add lib/monq/environment.rb spec/environment_spec.rb
|
|
780
|
+
git commit -m "feat: load namespace models and bind Mongoid clients"
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
### Task 6: CLI and App runner
|
|
786
|
+
|
|
787
|
+
**Files:**
|
|
788
|
+
- Create: `lib/monq/cli.rb`
|
|
789
|
+
- Create: `lib/monq/app.rb`
|
|
790
|
+
- Create: `spec/app_spec.rb`
|
|
791
|
+
|
|
792
|
+
- [ ] **Step 1: Write the failing test**
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
# frozen_string_literal: true
|
|
796
|
+
|
|
797
|
+
RSpec.describe Monq::Cli do
|
|
798
|
+
it "parses multiple environments" do
|
|
799
|
+
opts = described_class.parse!(%w[-e blog.dev,ops.prod])
|
|
800
|
+
expect(opts.environments).to eq(%w[blog.dev ops.prod])
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
it "rejects connection flags with multiple environments" do
|
|
804
|
+
expect { described_class.parse!(%w[-e a,b --uri mongodb://h/db]) }
|
|
805
|
+
.to raise_error(Monq::Error, /multiple/i)
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
RSpec.describe Monq::App do
|
|
810
|
+
it "evals -E and does not start pry" do
|
|
811
|
+
yaml = Tempfile.new(["monq", ".yml"])
|
|
812
|
+
yaml.write("blog.dev:\n host: h\n database: d\n namespace: BlogApp\n")
|
|
813
|
+
yaml.close
|
|
814
|
+
app = described_class.new(OpenStruct.new(config_file: yaml.path, environments: ["blog.dev"], code: "MONQ_RAN = 1"))
|
|
815
|
+
allow_any_instance_of(Monq::Environment).to receive(:connect!)
|
|
816
|
+
expect(Pry).not_to receive(:start) if defined?(Pry)
|
|
817
|
+
app.run!
|
|
818
|
+
expect(MONQ_RAN).to eq(1)
|
|
819
|
+
ensure
|
|
820
|
+
yaml&.unlink
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
826
|
+
|
|
827
|
+
Run: `bundle exec rspec spec/app_spec.rb`
|
|
828
|
+
Expected: FAIL, `Monq::Cli` missing
|
|
829
|
+
|
|
830
|
+
- [ ] **Step 3: Implement**
|
|
831
|
+
|
|
832
|
+
`lib/monq/cli.rb` uses `OptionParser` for the flags in spec section 4.1. `parse!` returns an `OpenStruct`. `start` calls `App.new(parse!(ARGV)).run!`. Multiple `-e` plus any connection flag raises `Monq::Error`.
|
|
833
|
+
|
|
834
|
+
`lib/monq/app.rb`:
|
|
835
|
+
|
|
836
|
+
```ruby
|
|
837
|
+
module Monq
|
|
838
|
+
class App
|
|
839
|
+
attr_reader :environments, :options
|
|
840
|
+
|
|
841
|
+
class << self
|
|
842
|
+
attr_accessor :instance
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
def initialize(options)
|
|
846
|
+
@options = options
|
|
847
|
+
self.class.instance = self
|
|
848
|
+
@environments = build_environments
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def run!
|
|
852
|
+
@environments.each(&:connect!)
|
|
853
|
+
load_initializer!
|
|
854
|
+
@environments.each(&:load_models!)
|
|
855
|
+
if @options.code && !@options.code.empty?
|
|
856
|
+
eval(@options.code, TOPLEVEL_BINDING, "-e")
|
|
857
|
+
elsif @options.args&.first
|
|
858
|
+
load(@options.args.first)
|
|
859
|
+
elsif !$stdin.isatty
|
|
860
|
+
eval($stdin.read, TOPLEVEL_BINDING, "stdin")
|
|
861
|
+
else
|
|
862
|
+
require "monq/repl"
|
|
863
|
+
Repl.new.start
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
`build_environments` resolves the config file (explicit `-c` missing → raise; no file and no CLI connection → raise), calls `Config.select_environments`, and maps each to `Environment.new(..., first: index.zero?, model_dir: File.expand_path("~/.monq.d"))`.
|
|
871
|
+
|
|
872
|
+
`load_initializer!`: if `-i` set and missing, raise; if default path missing, return; else `load` once.
|
|
873
|
+
|
|
874
|
+
`require_relative` both files from `lib/monq.rb`. `exe/monq` already calls `Monq::Cli.start`.
|
|
875
|
+
|
|
876
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
877
|
+
|
|
878
|
+
Run: `bundle exec rspec spec/app_spec.rb spec/config_spec.rb`
|
|
879
|
+
Expected: pass
|
|
880
|
+
|
|
881
|
+
- [ ] **Step 5: Commit**
|
|
882
|
+
|
|
883
|
+
```bash
|
|
884
|
+
git add lib/monq.rb lib/monq/cli.rb lib/monq/app.rb spec/app_spec.rb
|
|
885
|
+
git commit -m "feat: add CLI and App runner"
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
### Task 7: Presenter and Table
|
|
891
|
+
|
|
892
|
+
**Files:**
|
|
893
|
+
- Create: `lib/monq/presenter.rb`
|
|
894
|
+
- Create: `lib/monq/table.rb`
|
|
895
|
+
- Create: `spec/presenter_spec.rb`
|
|
896
|
+
|
|
897
|
+
- [ ] **Step 1: Write the failing test**
|
|
898
|
+
|
|
899
|
+
```ruby
|
|
900
|
+
# frozen_string_literal: true
|
|
901
|
+
|
|
902
|
+
require "bson"
|
|
903
|
+
|
|
904
|
+
RSpec.describe Monq::Presenter do
|
|
905
|
+
it "stringifies ObjectId" do
|
|
906
|
+
id = BSON::ObjectId.new
|
|
907
|
+
expect(described_class.scalar(id)).to eq(id.to_s)
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
it "truncates long hashes" do
|
|
911
|
+
text = described_class.scalar({ "a" => "x" * 200 })
|
|
912
|
+
expect(text.length).to be <= 121
|
|
913
|
+
expect(text).to end_with("…")
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
it "builds rows from the first document's keys" do
|
|
917
|
+
rows = described_class.rows([{ "a" => 1, "b" => 2 }, { "a" => 3, "c" => 9 }])
|
|
918
|
+
expect(rows.first).to eq(%w[a b])
|
|
919
|
+
expect(rows.last).to eq([3, nil])
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
925
|
+
|
|
926
|
+
Run: `bundle exec rspec spec/presenter_spec.rb`
|
|
927
|
+
Expected: FAIL
|
|
928
|
+
|
|
929
|
+
- [ ] **Step 3: Implement**
|
|
930
|
+
|
|
931
|
+
`Presenter.scalar` follows spec 6.2 (ObjectId, Time, Binary, Decimal128, Hash/Array truncated at 120). `Presenter.rows(docs)` uses `docs.first.keys`, missing keys become `nil`.
|
|
932
|
+
|
|
933
|
+
`Table` wraps `Terminal::Table` and supports `:terminal`, `:md`, `:org` the same way Arql's `lib/arql/table.rb` does (copy the style branches, do not require arql).
|
|
934
|
+
|
|
935
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
936
|
+
|
|
937
|
+
Run: `bundle exec rspec spec/presenter_spec.rb`
|
|
938
|
+
Expected: pass
|
|
939
|
+
|
|
940
|
+
- [ ] **Step 5: Commit**
|
|
941
|
+
|
|
942
|
+
```bash
|
|
943
|
+
git add lib/monq/presenter.rb lib/monq/table.rb spec/presenter_spec.rb lib/monq.rb
|
|
944
|
+
git commit -m "feat: present BSON values as tables"
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
---
|
|
948
|
+
|
|
949
|
+
### Task 8: t/v/vd on documents, criteria, views, arrays
|
|
950
|
+
|
|
951
|
+
**Files:**
|
|
952
|
+
- Create: `lib/monq/ext/document.rb`
|
|
953
|
+
- Create: `lib/monq/ext/criteria.rb`
|
|
954
|
+
- Create: `lib/monq/ext/view.rb`
|
|
955
|
+
- Create: `lib/monq/ext/array.rb`
|
|
956
|
+
- Create: `spec/display_spec.rb`
|
|
957
|
+
|
|
958
|
+
- [ ] **Step 1: Write the failing test**
|
|
959
|
+
|
|
960
|
+
```ruby
|
|
961
|
+
# frozen_string_literal: true
|
|
962
|
+
|
|
963
|
+
RSpec.describe "display" do
|
|
964
|
+
it "caps an unlimited criteria at 500 and reports it" do
|
|
965
|
+
criteria = double("criteria", options: {})
|
|
966
|
+
allow(criteria).to receive(:limit).with(500).and_return(criteria)
|
|
967
|
+
docs = [{ "_id" => "a", "name" => "n" }]
|
|
968
|
+
allow(criteria).to receive(:to_a).and_return(docs)
|
|
969
|
+
expect { Monq::Ext.display_criteria(criteria) }.to output(/capped at 500/).to_stdout
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
it "does not cap when the caller set limit" do
|
|
973
|
+
criteria = double("criteria", options: { limit: 2 })
|
|
974
|
+
expect(criteria).not_to receive(:limit)
|
|
975
|
+
allow(criteria).to receive(:to_a).and_return([{ "_id" => "a" }])
|
|
976
|
+
expect { Monq::Ext.display_criteria(criteria) }.not_to output(/capped/).to_stdout
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
982
|
+
|
|
983
|
+
Run: `bundle exec rspec spec/display_spec.rb`
|
|
984
|
+
Expected: FAIL
|
|
985
|
+
|
|
986
|
+
- [ ] **Step 3: Implement**
|
|
987
|
+
|
|
988
|
+
`Monq::Ext.display_criteria(criteria)`:
|
|
989
|
+
|
|
990
|
+
- if `criteria.options[:limit].nil?`, `criteria = criteria.limit(500)` and set `capped = true`
|
|
991
|
+
- print `Table` of `Presenter.rows(criteria.to_a)`
|
|
992
|
+
- print `showing N rows (capped at 500)` only when capped
|
|
993
|
+
|
|
994
|
+
`v` returns `[keys, nil, *rows]` and does not print the cap line. `t` and `vd` print it.
|
|
995
|
+
|
|
996
|
+
Include the module into `Mongoid::Document`, `Mongoid::Criteria`, and `Mongo::Collection::View` (`view.rb` calls `to_a` then array `t`). Array `t` uses `Presenter.rows` when elements are Hash-like.
|
|
997
|
+
|
|
998
|
+
Do **not** define `Fund.t` as a class method.
|
|
999
|
+
|
|
1000
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1001
|
+
|
|
1002
|
+
Run: `bundle exec rspec spec/display_spec.rb spec/presenter_spec.rb`
|
|
1003
|
+
Expected: pass
|
|
1004
|
+
|
|
1005
|
+
- [ ] **Step 5: Commit**
|
|
1006
|
+
|
|
1007
|
+
```bash
|
|
1008
|
+
git add lib/monq/ext spec/display_spec.rb lib/monq.rb
|
|
1009
|
+
git commit -m "feat: add t/v display with a 500-row cap"
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
---
|
|
1013
|
+
|
|
1014
|
+
### Task 9: const_missing and Kernel helpers
|
|
1015
|
+
|
|
1016
|
+
**Files:**
|
|
1017
|
+
- Create: `lib/monq/ext/object.rb`
|
|
1018
|
+
- Create: `lib/monq/ext/kernel.rb`
|
|
1019
|
+
- Create: `spec/const_missing_spec.rb`
|
|
1020
|
+
|
|
1021
|
+
- [ ] **Step 1: Write the failing test**
|
|
1022
|
+
|
|
1023
|
+
```ruby
|
|
1024
|
+
# frozen_string_literal: true
|
|
1025
|
+
|
|
1026
|
+
RSpec.describe "const_missing" do
|
|
1027
|
+
it "resolves the first environment that defines the constant" do
|
|
1028
|
+
a = Module.new
|
|
1029
|
+
b = Module.new
|
|
1030
|
+
stub_const("Alpha", Class.new)
|
|
1031
|
+
a.const_set(:Alpha, Alpha)
|
|
1032
|
+
app = instance_double(Monq::App, environments: [
|
|
1033
|
+
instance_double(Monq::Environment, namespace_module: a),
|
|
1034
|
+
instance_double(Monq::Environment, namespace_module: b)
|
|
1035
|
+
])
|
|
1036
|
+
allow(Monq::App).to receive(:instance).and_return(app)
|
|
1037
|
+
expect(Object.const_missing(:Alpha)).to eq(Alpha)
|
|
1038
|
+
end
|
|
1039
|
+
end
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
Adjust if `Object.const_missing` cannot be called directly because the constant exists — remove it first or use a name that is not defined, e.g. `FundProbe`.
|
|
1043
|
+
|
|
1044
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1045
|
+
|
|
1046
|
+
Run: `bundle exec rspec spec/const_missing_spec.rb`
|
|
1047
|
+
Expected: FAIL or `NameError` from `super`
|
|
1048
|
+
|
|
1049
|
+
- [ ] **Step 3: Implement**
|
|
1050
|
+
|
|
1051
|
+
`object.rb` prepends/overrides `Object.const_missing` exactly as Arql does, walking `Monq::App.instance.environments`. `kernel.rb` defines `models`, `model_names`, `within_namespace`, `within_env`, `env_config`, and `q` (single environment only; otherwise stderr + nil).
|
|
1052
|
+
|
|
1053
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1054
|
+
|
|
1055
|
+
Run: `bundle exec rspec spec/const_missing_spec.rb`
|
|
1056
|
+
Expected: pass
|
|
1057
|
+
|
|
1058
|
+
- [ ] **Step 5: Commit**
|
|
1059
|
+
|
|
1060
|
+
```bash
|
|
1061
|
+
git add lib/monq/ext/object.rb lib/monq/ext/kernel.rb spec/const_missing_spec.rb lib/monq.rb
|
|
1062
|
+
git commit -m "feat: alias top-level model constants across environments"
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
---
|
|
1066
|
+
|
|
1067
|
+
### Task 10: Pry commands info, m, show-sql, reconnect, redefine
|
|
1068
|
+
|
|
1069
|
+
**Files:**
|
|
1070
|
+
- Create: `lib/monq/repl.rb`
|
|
1071
|
+
- Create: `lib/monq/multi_io.rb`
|
|
1072
|
+
- Create: `lib/monq/commands/info.rb`
|
|
1073
|
+
- Create: `lib/monq/commands/models.rb`
|
|
1074
|
+
- Create: `lib/monq/commands/show_sql.rb`
|
|
1075
|
+
- Create: `lib/monq/commands/reconnect.rb`
|
|
1076
|
+
- Create: `lib/monq/commands/redefine.rb`
|
|
1077
|
+
- Create: `spec/commands_spec.rb`
|
|
1078
|
+
|
|
1079
|
+
- [ ] **Step 1: Write the failing test**
|
|
1080
|
+
|
|
1081
|
+
```ruby
|
|
1082
|
+
# frozen_string_literal: true
|
|
1083
|
+
|
|
1084
|
+
RSpec.describe Monq::Commands::Info do
|
|
1085
|
+
it "masks passwords" do
|
|
1086
|
+
env = instance_double(
|
|
1087
|
+
Monq::Environment,
|
|
1088
|
+
name: "blog.dev",
|
|
1089
|
+
config: { uri: "mongodb://admin:secret@db.example.com:27017/blog_dev", database: "blog_dev" },
|
|
1090
|
+
client_name: :blog_dev,
|
|
1091
|
+
ping?: true,
|
|
1092
|
+
ssh_proxy: nil
|
|
1093
|
+
)
|
|
1094
|
+
text = described_class.render([env], /blog/)
|
|
1095
|
+
expect(text).to include("admin:***@")
|
|
1096
|
+
expect(text).not_to include("secret")
|
|
1097
|
+
expect(text).to include("blog_dev")
|
|
1098
|
+
end
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
RSpec.describe Monq::Commands::Redefine do
|
|
1102
|
+
it "refuses while sandbox is active" do
|
|
1103
|
+
env = instance_double(Monq::Environment, sandbox?: true)
|
|
1104
|
+
expect { described_class.run([env]) }.to raise_error(Monq::Error, /sandbox/)
|
|
1105
|
+
expect(env).not_to receive(:redefine!)
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1111
|
+
|
|
1112
|
+
Run: `bundle exec rspec spec/commands_spec.rb`
|
|
1113
|
+
Expected: FAIL
|
|
1114
|
+
|
|
1115
|
+
- [ ] **Step 3: Implement**
|
|
1116
|
+
|
|
1117
|
+
Command modules expose pure methods (`render`, `run`) that Pry command blocks call. `Info.render` uses `Config.mask_uri`. `Models.render` lists `env.models` with collection, class name, abbreviation. `ShowSql` subscribes `Mongo::Monitoring::COMMAND` and writes JSON to `App.log_io` (`MultiIO`). `Reconnect.run` calls `ping?` then `reconnect!` unless `sandbox?`. `Redefine.run` raises `Monq::Error` when any env `sandbox?`, else `redefine!`.
|
|
1118
|
+
|
|
1119
|
+
`Repl#start` sets the prompt `MONQ@<names>` and calls `Pry.start` unless `options.babel_compatable`.
|
|
1120
|
+
|
|
1121
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1122
|
+
|
|
1123
|
+
Run: `bundle exec rspec spec/commands_spec.rb`
|
|
1124
|
+
Expected: pass
|
|
1125
|
+
|
|
1126
|
+
- [ ] **Step 5: Commit**
|
|
1127
|
+
|
|
1128
|
+
```bash
|
|
1129
|
+
git add lib/monq/repl.rb lib/monq/multi_io.rb lib/monq/commands spec/commands_spec.rb lib/monq.rb
|
|
1130
|
+
git commit -m "feat: add info, models, show-sql, reconnect, and redefine commands"
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
---
|
|
1134
|
+
|
|
1135
|
+
### Task 11: SSH proxy
|
|
1136
|
+
|
|
1137
|
+
**Files:**
|
|
1138
|
+
- Create: `lib/monq/ssh_proxy.rb`
|
|
1139
|
+
- Modify: `lib/monq/environment.rb`
|
|
1140
|
+
- Create: `spec/ssh_proxy_spec.rb`
|
|
1141
|
+
|
|
1142
|
+
- [ ] **Step 1: Write the failing test**
|
|
1143
|
+
|
|
1144
|
+
```ruby
|
|
1145
|
+
# frozen_string_literal: true
|
|
1146
|
+
|
|
1147
|
+
RSpec.describe Monq::SSHProxy do
|
|
1148
|
+
it "returns the local host and port" do
|
|
1149
|
+
gateway = instance_double(Net::SSH::Gateway, open: 27018, shutdown!: nil, active?: true)
|
|
1150
|
+
allow(Net::SSH::Gateway).to receive(:new).and_return(gateway)
|
|
1151
|
+
proxy = described_class.new(host: "jump", user: "deploy", port: 22, forward_host: "db.example.com", forward_port: 27017, local_port: 27018)
|
|
1152
|
+
expect(proxy.database_host_port).to eq(host: "127.0.0.1", port: 27018)
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
it "reconnects only when inactive" do
|
|
1156
|
+
gateway = instance_double(Net::SSH::Gateway, open: 27018, shutdown!: nil, active?: true)
|
|
1157
|
+
allow(Net::SSH::Gateway).to receive(:new).and_return(gateway)
|
|
1158
|
+
proxy = described_class.new(host: "jump", user: "deploy", forward_host: "db.example.com", forward_port: 27017)
|
|
1159
|
+
expect(proxy).not_to receive(:reconnect!)
|
|
1160
|
+
proxy.reconnect
|
|
1161
|
+
end
|
|
1162
|
+
end
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1166
|
+
|
|
1167
|
+
Run: `bundle exec rspec spec/ssh_proxy_spec.rb`
|
|
1168
|
+
Expected: FAIL
|
|
1169
|
+
|
|
1170
|
+
- [ ] **Step 3: Implement**
|
|
1171
|
+
|
|
1172
|
+
`SSHProxy#initialize` calls `Net::SSH::Gateway.new` and `open`. Password is optional. `reconnect` calls `reconnect!` only when `!active?`. `reconnect!` shuts down and opens the **same** local port.
|
|
1173
|
+
|
|
1174
|
+
`Environment#connect!` if `config[:ssh]`: build proxy from `Config.ssh_forward_target`, put `{ local_host:, local_port: }` into the hash passed to `Config.client_options` so Task 3's rewrite runs. `reconnect!` calls `ssh_proxy.reconnect!` before rebuilding the client.
|
|
1175
|
+
|
|
1176
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1177
|
+
|
|
1178
|
+
Run: `bundle exec rspec spec/ssh_proxy_spec.rb spec/config_spec.rb`
|
|
1179
|
+
Expected: pass
|
|
1180
|
+
|
|
1181
|
+
- [ ] **Step 5: Commit**
|
|
1182
|
+
|
|
1183
|
+
```bash
|
|
1184
|
+
git add lib/monq/ssh_proxy.rb lib/monq/environment.rb spec/ssh_proxy_spec.rb
|
|
1185
|
+
git commit -m "feat: tunnel Mongo connections over SSH"
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1188
|
+
---
|
|
1189
|
+
|
|
1190
|
+
### Task 12: Sandbox
|
|
1191
|
+
|
|
1192
|
+
**Files:**
|
|
1193
|
+
- Create: `lib/monq/commands/sandbox.rb`
|
|
1194
|
+
- Modify: `lib/monq/environment.rb`
|
|
1195
|
+
- Create: `spec/sandbox_spec.rb`
|
|
1196
|
+
|
|
1197
|
+
- [ ] **Step 1: Write the failing test**
|
|
1198
|
+
|
|
1199
|
+
```ruby
|
|
1200
|
+
# frozen_string_literal: true
|
|
1201
|
+
|
|
1202
|
+
RSpec.describe Monq::Commands::Sandbox do
|
|
1203
|
+
def env(hello)
|
|
1204
|
+
client = double("client")
|
|
1205
|
+
db = double("db")
|
|
1206
|
+
allow(client).to receive(:database).and_return(db)
|
|
1207
|
+
allow(db).to receive(:command).with(hello: 1).and_return(double(documents: [hello]))
|
|
1208
|
+
session = double("session", start_transaction: nil, abort_transaction: nil, end_session: nil)
|
|
1209
|
+
allow(client).to receive(:start_session).and_return(session)
|
|
1210
|
+
instance_double(Monq::Environment, client: client, name: "blog.dev", sandbox?: false).tap do |e|
|
|
1211
|
+
allow(e).to receive(:bind_session)
|
|
1212
|
+
allow(e).to receive(:clear_session)
|
|
1213
|
+
end
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
it "does not start a session when any environment is standalone" do
|
|
1217
|
+
good = env("ok" => 1, "setName" => "rs0")
|
|
1218
|
+
bad = env("ok" => 1)
|
|
1219
|
+
expect(good.client).not_to receive(:start_session)
|
|
1220
|
+
expect { described_class.enter([good, bad]) }.to raise_error(Monq::Error, /replica set|mongos/i)
|
|
1221
|
+
end
|
|
1222
|
+
end
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1226
|
+
|
|
1227
|
+
Run: `bundle exec rspec spec/sandbox_spec.rb`
|
|
1228
|
+
Expected: FAIL
|
|
1229
|
+
|
|
1230
|
+
- [ ] **Step 3: Implement**
|
|
1231
|
+
|
|
1232
|
+
`Sandbox.enter(envs)`:
|
|
1233
|
+
|
|
1234
|
+
1. Collect `hello` for every env. If any lacks `setName` and `msg != "isdbgrid"`, raise `Monq::Error` and return **before** `start_session`.
|
|
1235
|
+
2. For each env, `start_session` + `start_transaction` + `bind_session`. On failure, `abort_transaction` + `end_session` the earlier ones and raise.
|
|
1236
|
+
|
|
1237
|
+
`Environment#bind_session(session)` stores the session on **that** client (client instance variable or a wrapper). Do not call `Mongoid::Threaded.set_session`.
|
|
1238
|
+
|
|
1239
|
+
`Sandbox.quit` aborts every session. `enter` when already active prints and returns.
|
|
1240
|
+
|
|
1241
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1242
|
+
|
|
1243
|
+
Run: `bundle exec rspec spec/sandbox_spec.rb spec/commands_spec.rb`
|
|
1244
|
+
Expected: pass
|
|
1245
|
+
|
|
1246
|
+
- [ ] **Step 5: Commit**
|
|
1247
|
+
|
|
1248
|
+
```bash
|
|
1249
|
+
git add lib/monq/commands/sandbox.rb lib/monq/environment.rb spec/sandbox_spec.rb
|
|
1250
|
+
git commit -m "feat: add all-or-nothing Mongo sandbox transactions"
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
---
|
|
1254
|
+
|
|
1255
|
+
### Task 13: CSV and Excel export
|
|
1256
|
+
|
|
1257
|
+
**Files:**
|
|
1258
|
+
- Modify: `lib/monq/ext/array.rb`
|
|
1259
|
+
- Modify: `lib/monq/ext/document.rb`
|
|
1260
|
+
- Modify: `lib/monq/ext/criteria.rb`
|
|
1261
|
+
- Create: `spec/export_spec.rb`
|
|
1262
|
+
|
|
1263
|
+
- [ ] **Step 1: Write the failing test**
|
|
1264
|
+
|
|
1265
|
+
```ruby
|
|
1266
|
+
# frozen_string_literal: true
|
|
1267
|
+
|
|
1268
|
+
require "csv"
|
|
1269
|
+
|
|
1270
|
+
RSpec.describe Array do
|
|
1271
|
+
it "writes csv without the 500-row display cap" do
|
|
1272
|
+
path = File.join(Dir.tmpdir, "monq-export.csv")
|
|
1273
|
+
[{ "a" => 1 }, { "a" => 2 }].write_csv(path)
|
|
1274
|
+
rows = CSV.read(path)
|
|
1275
|
+
expect(rows.size).to eq(3)
|
|
1276
|
+
ensure
|
|
1277
|
+
File.delete(path) if path && File.exist?(path)
|
|
1278
|
+
end
|
|
1279
|
+
end
|
|
1280
|
+
```
|
|
1281
|
+
|
|
1282
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1283
|
+
|
|
1284
|
+
Run: `bundle exec rspec spec/export_spec.rb`
|
|
1285
|
+
Expected: FAIL, `write_csv` missing
|
|
1286
|
+
|
|
1287
|
+
- [ ] **Step 3: Implement**
|
|
1288
|
+
|
|
1289
|
+
`Array#write_csv` writes UTF-8 CSV with a header from the first hash's keys. `Array#write_excel` uses `Axlsx::Package` one sheet. Document and Criteria delegate to `to_a` **without** the 500 cap. `vd` shells out to `vd` only when `which vd` succeeds; otherwise print the install hint.
|
|
1290
|
+
|
|
1291
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1292
|
+
|
|
1293
|
+
Run: `bundle exec rspec spec/export_spec.rb spec/display_spec.rb`
|
|
1294
|
+
Expected: pass
|
|
1295
|
+
|
|
1296
|
+
- [ ] **Step 5: Commit**
|
|
1297
|
+
|
|
1298
|
+
```bash
|
|
1299
|
+
git add lib/monq/ext spec/export_spec.rb
|
|
1300
|
+
git commit -m "feat: export query results to CSV and Excel"
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
---
|
|
1304
|
+
|
|
1305
|
+
### Task 14: Optional blog_dev integration spec and README
|
|
1306
|
+
|
|
1307
|
+
**Files:**
|
|
1308
|
+
- Create: `spec/integration/blog_dev_spec.rb`
|
|
1309
|
+
- Modify: `README.md`
|
|
1310
|
+
|
|
1311
|
+
- [ ] **Step 1: Write the spec (skips unless env is set)**
|
|
1312
|
+
|
|
1313
|
+
```ruby
|
|
1314
|
+
# frozen_string_literal: true
|
|
1315
|
+
|
|
1316
|
+
RSpec.describe "blog_dev", :integration do
|
|
1317
|
+
before do
|
|
1318
|
+
skip "set MONQ_TEST_URI and MONQ_TEST_DB" unless ENV["MONQ_TEST_URI"] && ENV["MONQ_TEST_DB"]
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1321
|
+
it "counts users" do
|
|
1322
|
+
klass = Class.new do
|
|
1323
|
+
include Mongoid::Document
|
|
1324
|
+
store_in collection: "users", client: :default, database: ENV.fetch("MONQ_TEST_DB")
|
|
1325
|
+
end
|
|
1326
|
+
Mongo::Client.new(ENV.fetch("MONQ_TEST_URI"), database: ENV.fetch("MONQ_TEST_DB")) do |client|
|
|
1327
|
+
Mongoid::Clients.set(:default, client)
|
|
1328
|
+
expect(klass.count).to be >= 1
|
|
1329
|
+
end
|
|
1330
|
+
end
|
|
1331
|
+
end
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
This spec does not fail CI when the variables are unset.
|
|
1335
|
+
|
|
1336
|
+
- [ ] **Step 2: Run it skipped**
|
|
1337
|
+
|
|
1338
|
+
Run: `bundle exec rspec spec/integration/blog_dev_spec.rb`
|
|
1339
|
+
Expected: 1 skipped (or 1 passed if the variables are already in the shell)
|
|
1340
|
+
|
|
1341
|
+
- [ ] **Step 3: Replace README usage**
|
|
1342
|
+
|
|
1343
|
+
Document:
|
|
1344
|
+
|
|
1345
|
+
- `gem install` / `bundle exec exe/monq`
|
|
1346
|
+
- `~/.monq.d/init.yml` example using `uri` + `database: blog_dev` + `namespace: Blog` (placeholder password, not the real one)
|
|
1347
|
+
- `~/.monq.d/blog.rb` example: `class User` with `store_in collection: 'users'`, string date fields, `embeds_one` `_meta`, and a `posts` method using a natural key
|
|
1348
|
+
- Commands: `info`, `m`, `show-sql`, `reconnect`, `sandbox-enter`, `redefine`
|
|
1349
|
+
- SSH limitation: one seed, `direct_connection`, no `replicaSet`, no SRV+SSH
|
|
1350
|
+
- Sandbox needs a replica set; driver writes via the model are covered, raw `client[:c].insert_one` is not
|
|
1351
|
+
|
|
1352
|
+
- [ ] **Step 4: Run the full suite**
|
|
1353
|
+
|
|
1354
|
+
Run: `bundle exec rspec`
|
|
1355
|
+
Expected: 0 failures. Integration skipped unless env vars are set.
|
|
1356
|
+
|
|
1357
|
+
- [ ] **Step 5: Commit**
|
|
1358
|
+
|
|
1359
|
+
```bash
|
|
1360
|
+
git add spec/integration/blog_dev_spec.rb README.md
|
|
1361
|
+
git commit -m "docs: describe monq usage and add optional blog_dev spec"
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
---
|
|
1365
|
+
|
|
1366
|
+
## Spec coverage check
|
|
1367
|
+
|
|
1368
|
+
| Spec section | Task |
|
|
1369
|
+
|--------------|------|
|
|
1370
|
+
| CLI flags, multi-env ban | 6 |
|
|
1371
|
+
| YAML select, reserved `default`, no `-e` | 2 |
|
|
1372
|
+
| URI vs fields, database override, mask | 3 |
|
|
1373
|
+
| SSH rewrite, drop replicaSet, SRV reject | 3, 11 |
|
|
1374
|
+
| Named client + `:default` is the same instance | 4, 11 |
|
|
1375
|
+
| Prefix load, store_in merge, abbreviations | 5 |
|
|
1376
|
+
| redefine unregisters embedded + refuses in sandbox | 5, 10 |
|
|
1377
|
+
| const_missing, Kernel helpers | 9 |
|
|
1378
|
+
| t/v 500 cap, first-doc keys, no class `Model.t` | 7, 8 |
|
|
1379
|
+
| info/m/show-sql/reconnect | 10 |
|
|
1380
|
+
| sandbox all-or-nothing, per-client session | 12 |
|
|
1381
|
+
| CSV/Excel uncapped | 13 |
|
|
1382
|
+
| blog_dev optional integration, secrets not in git | 14 |
|
|
1383
|
+
|
|
1384
|
+
Out of scope, matching the spec: auto models, DDL, Ransack, charts, mongodump, `model_names`, class-level `Model.t`.
|