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,401 @@
1
+ # Monq: MongoDB + Pry 查询工作台
2
+
3
+ **Date:** 2026-10-24
4
+ **Status:** Draft for implementation
5
+ **Repo:** `monq`(`bundle gem monq` 脚手架,独立于 `arql`)
6
+
7
+ ## 1. 问题与目标
8
+
9
+ Arql 把 ActiveRecord + Pry 做成 SQL 工作台:YAML 多环境、SSH 隧道、动态模型、Pry 命令、表格/导出。MongoDB 没有表 schema,不能作为 Arql 的 adapter 塞进去。
10
+
11
+ Monq 是平行产品:**Mongoid + Pry 的 Mongo 工作台**。同一套 REPL 肌肉记忆,数据层换成 Mongoid。用户在 `~/.monq.d/init.yml` 里写连接,在 `~/.monq.d/*.rb` 里写 `Mongoid::Document` 模型。不扫集合自动造类。
12
+
13
+ 第一版要对齐 Arql 的 REPL 体验(`info` / `m` / `sandbox` / `show-sql` / `reconnect` / `t`/`v`/`vd` / CSV·Excel),而不是只做一个能 `load` 模型的 console。
14
+
15
+ 成功标准:
16
+
17
+ - `monq -e blog.dev` 连上 `blog_dev`,load `~/.monq.d/blog.rb`,进入 Pry。
18
+ - `User.where(name: 'alice').t` 打出表格。
19
+ - 多环境 `monq -e a,b` 各有 namespace 和 Mongoid client。
20
+ - 未建模集合可通过 `Blog.client[:jobs].find(...).to_a.t` 查询。
21
+
22
+ ## 2. 已拍板的约束
23
+
24
+ | 决策 | 选择 |
25
+ |------|------|
26
+ | 模型来源 | 只加载用户写的模型,不 `list_collections` 造类 |
27
+ | 模型 API | `Mongoid::Document` |
28
+ | 第一版范围 | 尽量对齐 Arql REPL |
29
+ | 模型文件 | 文件名 = 环境名前缀(`blog.dev` → `blog.rb`) |
30
+ | 连接配置 | URI 优先,否则拆字段拼 client |
31
+ | 多环境 | 与 Arql 相同,顶层 `const_missing` 按 `-e` 顺序别名 |
32
+ | SSH | 第一版内置,结构对齐 Arql |
33
+ | 实现策略 | 平行复制 Arql 的壳,不抽 `arql-shell`,不依赖 `arql` gem |
34
+ | 测试库 | `blog_dev` @ `db.example.com`(凭证只放本机 YAML / 环境变量,不进 git) |
35
+
36
+ ## 3. 架构与启动流程
37
+
38
+ ```
39
+ exe/monq
40
+ └─ Monq::Cli.start
41
+ ├─ 读配置(默认 ~/.monq.d/init.yml)
42
+ ├─ 解析 -e env1,env2(分隔符:`,` `+` `:`)
43
+ └─ App.new(options).run!
44
+ ├─ 每个环境 new Environment
45
+ │ ├─ SSH 隧道(可选)
46
+ │ ├─ 创建 namespace 模块
47
+ │ ├─ 注册命名 Mongoid client
48
+ │ └─ load 模型文件
49
+ │ ~/.monq.d/init.rb # 全局,整个进程 load 一次
50
+ │ ~/.monq.d/<prefix>.rb # load 进该环境的 namespace 模块
51
+ ├─ 扫描 namespace 内的 Mongoid::Document
52
+ │ 顶层文档类补 store_in client: <client_name>
53
+ │ 嵌入文档(embedded)不补
54
+ └─ 执行
55
+ ├─ TTY 且无 -E / 文件 → Pry
56
+ ├─ -E CODE → eval
57
+ ├─ 文件参数 → load
58
+ └─ STDIN 非 tty → eval stdin
59
+ ```
60
+
61
+ 配置路径(先命中的文件生效):
62
+
63
+ | 用途 | 候选路径 |
64
+ |------|----------|
65
+ | 连接 YAML | `-c` > `~/.monq.d/init.yml` > `~/.monq.d/init.yaml` > `~/.monq.yml` > `~/.monq.yaml` |
66
+ | 全局 initializer | `-i` > `~/.monq.d/init.rb` > `~/.monq.rb` |
67
+ | 环境模型 | 始终从 `~/.monq.d/<prefix>.rb` 加载。若 `-i` 指向别的文件,前缀模型仍从 `~/.monq.d/` 加载(不随 `-i` 搬家) |
68
+
69
+ 配置文件缺失:
70
+
71
+ - 未传 `-c`,候选 YAML 都不存在:仅当 CLI 本身「足够连接信息」(见 4.2)时允许无 YAML 启动;否则退出 1,提示创建 `~/.monq.d/init.yml` 或传 `-e` / `--uri`。
72
+ - 传了 `-c` 但文件不存在:退出 1,不回退到默认路径。
73
+ - 未传 `-i`,默认 `init.rb` 不存在:静默跳过。
74
+ - 传了 `-i` 但文件不存在:退出 1。
75
+
76
+ `App` 是单例。Pry 命令、`Kernel` 扩展只通过 `App.instance` 拿环境列表。
77
+
78
+ CLI 与 YAML 合并:
79
+
80
+ - **多个** `-e` 环境:禁止 CLI 覆盖单连接字段(`--uri` `--db-*` `--ssh-*`)。出现则退出 1。
81
+ - **一个** `-e` 或合成的临时 `default`:CLI 覆盖 YAML。标量键(`uri` `host` `port` `database` `username` `password` `auth_source` `replica_set` `ssl` `namespace`)CLI 非空则整键替换。`ssh:` **deep merge**,CLI 提供的 ssh 子键覆盖 YAML 的对应子键,未提供的子键保留。
82
+ - YAML 里名为 `default` 或 `arql` 的键只允许当锚点(`&default`),**不能**作为 `-e default` 的环境。尝试使用则退出 1,提示保留名。无 `-e` 时也**绝不**把 YAML 的 `default:` 段当成要连接的环境。
83
+ - 两个 `-e` 环境解析出**相同 `namespace` 模块名**:退出 1(一个模块不能绑两个 client)。
84
+ - `run!` 互斥优先级(先命中的执行,只执行一种):`-E` > 文件参数 > 非 tty STDIN > TTY 则 Pry。`-E` 与文件同时出现时只 eval `-E`,忽略文件。
85
+
86
+ ## 4. 单元与接口
87
+
88
+ 每个单元只做一件事,命令层不直接碰 Mongoid 配置哈希。
89
+
90
+ ### 4.1 `Monq::Cli`
91
+
92
+ - 解析 ARGV 到 `OpenStruct`。
93
+ - 选项:`-c` `-i` `-e` `-E` `-b`(babel)`-S` `-w` `-A` `--version` `--help`。
94
+ - 连接覆盖:`--uri` `--db-host` `--db-port` `--db-name` `--db-user` `--db-password` `--auth-source`。
95
+ - SSH:`--ssh-host` `--ssh-port` `--ssh-user` `--ssh-password` `--ssh-local-port`。
96
+ - 不连接数据库。把 options 交给 `App`。
97
+
98
+ ### 4.2 `Monq::App`
99
+
100
+ - 读 YAML(`YAML.safe_load(..., aliases: true)`,兼容无 aliases 的旧 Psych)。YAML 键 `default` / `arql` 不当作可连接环境(见上)。
101
+ - **有 `-e`:** 只切片这些键。缺键 → 退出 1,列出 YAML 里除保留名外的环境键。
102
+ - **无 `-e`:** **不**切片 YAML。仅当 CLI 有「足够连接信息」时,合成名为 `default` 的**临时**环境(不读 YAML 的 `default:` 锚点)。否则退出 1,提示使用 `-e`。
103
+ - 「足够连接信息」:`--uri` 有值,**或** `--db-host` 与 `--db-name` 都有值。缺省端口 `27017`。仅有 `--db-name` 或仅有 host 都不够。
104
+ - 为每个环境实例化 `Environment` 并 `connect!`。然后进程级 `load` 一次全局 `init.rb`。然后各环境 `load_models!`。
105
+ - `run!` 选择 REPL / eval / load。
106
+ - 持有 `log_io`(`MultiIO`),供 `show-sql` 与 `-S`/`-w`/`-A` 使用。
107
+
108
+ ### 4.3 `Monq::Environment`
109
+
110
+ 一个 `-e` 段的全部运行时状态。
111
+
112
+ | 方法 | 职责 |
113
+ |------|------|
114
+ | `name` | YAML 键,如 `blog.dev` |
115
+ | `namespace` | 模块名字符串 |
116
+ | `namespace_module` | 该模块 |
117
+ | `client_name` | Mongoid client 符号。`blog.dev` → `:blog_dev`(非 `[A-Za-z0-9]` 换成 `_`) |
118
+ | `config` | 该环境合并后的配置 Hash(含自定义键) |
119
+ | `connect!` | SSH(若有)→ 注册 client → `hello` |
120
+ | `reconnect!` / `ping?` | 重建 client(仍用隧道改写后的 `127.0.0.1:local_port` 与去掉拓扑后的 options,不得拿原始 URI/host/seeds 重建;第一个环境的同一实例再映射 `:default`)/ `hello`(与 sandbox 探活同一命令,不用 `ping`) |
121
+ | `load_models!` | load 前缀文件到 namespace,绑定 `store_in client:`,生成缩写常量 |
122
+ | `redefine!` | 见 5.3:从 namespace **和** `Mongoid.models` 注销后再 load。不重新 load 全局 `init.rb`。Pry 命令在 sandbox 中拒绝;本方法若在 sandbox 中被直接调用也拒绝(`raise`),不悄悄 quit |
123
+ | `models` | 顶层 Document 类数组(排除 embedded) |
124
+ | `client` | `Mongo::Client` |
125
+ | `sandbox_enter` / `sandbox_quit` | 见第 7 节 |
126
+ | `command(doc)` | `client.database.command(doc)`。namespace 模块上的 `q` 转发到这里 |
127
+
128
+ `Environment` 不负责打表、不注册 Pry 命令。
129
+
130
+ ### 4.4 `Monq::SSHProxy`
131
+
132
+ - 输入:`host` `user` `port` + 要转发的 Mongo `forward_host`/`forward_port` + 可选 `local_port` + 可选 `password`。
133
+ - `password` 可空:走 SSH agent / 默认密钥(`Net::SSH::Gateway.new` 不传 password)。
134
+ - 用 `Net::SSH::Gateway` 打开本地端口。
135
+ - `database_host_port` → `{ host: '127.0.0.1', port: local_port }`。
136
+ - `active?`:网关是否仍活着。
137
+ - `reconnect`:仅当 `!active?` 时 `reconnect!`。
138
+ - `reconnect!`:关掉旧网关,用**同一** `local_port` 重新 `open` 到同一 `forward_host:forward_port`。
139
+
140
+ 凡配置了 `ssh`(无论连接来自 `uri` 还是 `host`/`port`/`replica_set`):
141
+
142
+ - `forward_host`/`forward_port` 取 URI 第一个 host/port,或拆字段的 `host`/`port`(缺省 `27017`)。
143
+ - 注册 client 时 hosts **只保留** `127.0.0.1:local_port` 一条 seed,并设置 `direct_connection: true`。
144
+ - **同时丢掉拓扑选项**,否则 Ruby driver 会拒绝 `direct_connection: true` + `replicaSet`:删除 `replica_set` / URI 的 `replicaSet`、`connect: :replica_set`、以及其它 seed。**保留** auth、database、auth_source、ssl、用户名密码。
145
+ - `mongodb+srv://`(SRV)+ `ssh`:退出 1。SRV 主机不是可转发的 mongod seed;第一版不解析 SRV。无 ssh 的 SRV URI 允许。
146
+ - 第一版不转发其余 replica 成员;README 写明「SSH 只走第一个 seed + direct_connection,不带 replicaSet」。
147
+
148
+ `Environment#reconnect!` 必须:先 `ssh_proxy.reconnect!`(若有 ssh),再关闭并按**改写后**的 host/port 与**同一套去掉拓扑后的 options** 注册同名 client,不得拿未经改写的原始 URI/host/seeds 重建。若该环境是 `-e` 列表第一个,把 **同一个** client 实例再映射为 `:default`。
149
+
150
+ ### 4.5 `Monq::Repl`
151
+
152
+ - 自定义 Pry prompt:`MONQ@<env[+env]> [sandbox] [n] ❯`。
153
+ - `-b` 时不改 prompt。
154
+ - `Pry.start` 前 `require 'monq/commands'`。
155
+
156
+ ### 4.6 `Monq::Commands::*`
157
+
158
+ 每个文件注册 Pry 命令,读 `App.instance.environments`。命令逻辑放在模块单例方法上,便于单测字符串输出。
159
+
160
+ ### 4.7 展示
161
+
162
+ - `Monq::Table`:terminal / md / org / HTML(IRuby 若存在)。
163
+ - `Monq::VD`:把行写成 CSV 交给 `vd`。
164
+ - `Monq::Presenter`:BSON → 可打印(见 6.2)。
165
+ - `Monq::MultiIO`:日志扇出;非 tty 剥 ANSI。
166
+
167
+ ### 4.8 扩展
168
+
169
+ | 文件 | 给谁 |
170
+ |------|------|
171
+ | `ext/object.rb` | `const_missing` 顶层别名;`j`/`jj` 可选,非必须 |
172
+ | `ext/kernel.rb` | `models` `model_names` `within_namespace` `within_env` `env_config` `generate_csv` `parse_excel` 等;全局 `q` 仅单环境 |
173
+ | `ext/document.rb` | Mongoid 文档实例 `t` `v` `vd` `write_csv` `write_excel` |
174
+ | `ext/criteria.rb` | `Mongoid::Criteria` 同上,先物化 |
175
+ | `ext/array.rb` `ext/hash.rb` | 与 Arql 同:数组/Hash 打表与导出。Hash 的 `write_excel` 多 sheet |
176
+ | `ext/view.rb` | `Mongo::Collection::View#t` 等:`to_a` 后走 Array |
177
+
178
+ 嵌入文档实例也走 Document 扩展(当一行的嵌套对象打印)。
179
+
180
+ ## 5. 配置与模型加载
181
+
182
+ ### 5.1 YAML
183
+
184
+ ```yaml
185
+ default: &default
186
+ auth_source: admin
187
+
188
+ blog.dev:
189
+ <<: *default
190
+ uri: mongodb://USER:PASS@db.example.com:27017/?authSource=admin
191
+ database: blog_dev
192
+ namespace: Blog
193
+ ssh:
194
+ host: jump.example.com
195
+ user: deploy
196
+ port: 22
197
+ local_port: 27018
198
+ ```
199
+
200
+ | 键 | 规则 |
201
+ |----|------|
202
+ | `uri` | 有则作为 client 地址来源。密码可含需 URL 编码的字符 |
203
+ | `database` | 逻辑库名。URI 路径无库名时**必填**。测试库为 `blog_dev` |
204
+ | `host` `port` `username` `password` `auth_source` `replica_set` `ssl` | 无 `uri` 时拼 hosts/options |
205
+ | `namespace` | 缺省:环境名里非字母数字当分隔,CamelCase。`blog.dev` → `BlogDev` |
206
+ | `ssh` | 同 Arql:`host` `port` `user` `password` `local_port` |
207
+ | 其它键 | 原样保留,`env_config(/blog/)` 可读 |
208
+
209
+ URI 与 `database` 同时存在:client 连 URI,`use`/`database` 以 `database` 键为准(覆盖 URI path)。
210
+
211
+ ### 5.2 前缀匹配
212
+
213
+ 环境名按 `.` `_` `-` 切第一段为前缀。
214
+
215
+ - `blog.dev` → `blog.rb`
216
+ - `blog` → `blog.rb`
217
+
218
+ 前缀 `.rb` 不存在:警告,不失败(允许只连库、用 `client[:coll]` 查)。默认路径的全局 `init.rb` 不存在:静默跳过。显式 `-i` 缺失见第 3 节。
219
+
220
+ `load` 前缀文件时用 `namespace_module.module_eval` 等价上下文(`Kernel.load(path, wrap)` 不能注入模块;实现用 `namespace_module.module_eval(File.read(path), path)`,这样文件里顶层 `class User` 成为 `Blog::User`)。
221
+
222
+ 全局 `init.rb` 用普通 `load`,在 `Object` 上下文,供跨环境 helper。**每个进程只 load 一次**,在所有 `Environment#connect!` 之后、各环境 `load_models!` 之前。
223
+
224
+ ### 5.3 绑定 client
225
+
226
+ `load_models!` 之后:
227
+
228
+ ```
229
+ 对 namespace_module 自身常量(第一版不深搜子模块)
230
+ 若 klass < Mongoid::Document
231
+ 若 klass.embedded? → 跳过(不 store_in client)
232
+ 若 storage 上的 client 是用户显式写的非 :default 名 → 不覆盖
233
+ 否则(含 Mongoid 默认的 client: :default)→ store_in **合并** client: client_name,保留已有 collection / store_as / cap 等
234
+ ```
235
+
236
+ 判定「用户显式指定了 client」:**仅当** `store_in` 的 client 存在且 **不是** `:default`。`include Mongoid::Document` 会把 client 设成 `:default`,不能把「有 client 键」当成用户指定。
237
+
238
+ 用户可在模型里写 `store_in collection: 'users'`;缺省 collection 仍是 Mongoid 对类名的 pluralize,所以 **非 pluralize 默认值的集合名必须显式 `store_in collection:`**。README 写明:前缀文件里写 `class User`,不要写 `class Blog::User`(已经在模块上下文里)。
239
+
240
+ `:default` client:Mongoid 在 `include Mongoid::Document` 时会碰 default client。规则:每个环境把自己的 **同一个** `Mongo::Client` 实例注册为命名 client(`:blog_dev`);**另外把 `-e` 列表第一个环境的同一实例再映射为 `:default`**(不是再 `Mongo::Client.new` 一份)。多环境时未改绑的类会落到第一个环境——这就是为什么 load 之后还要把非显式 client 改成该环境的命名 client。
241
+
242
+ `redefine!`:
243
+
244
+ 1. 若全局 sandbox 已启用:`raise`,不修改模型、不 quit sandbox。Pry 命令同样拒绝。
245
+ 2. 收集本 namespace 中 Monq 挂上的常量:顶层 Document、**其嵌入类**(`embedded_relations` / 嵌套 `Class`)、缩写。
246
+ 3. 对这些类:从 `Mongoid.models`(及所用版本等价注册表)删除,再 `namespace_module.remove_const`。
247
+ 4. 再 `load_models!`。
248
+
249
+ ### 5.4 缩写与顶层别名
250
+
251
+ - 缩写:类名中的大写字母(`BlogPost` → `BP`)。冲突则 `BP2`、`BP3`… 挂在同一 namespace。
252
+ - `Object.const_missing`:按 `App` 里环境顺序,找第一个 `namespace_module.const_defined?(name)` 的常量。找不到则 `super`。
253
+ - 第一版不做 YAML `model_names` 覆盖。
254
+
255
+ ### 5.5 Namespace 模块 API
256
+
257
+ 在 `create_namespace_module` 时定义:
258
+
259
+ - `Blog.client` → `Mongo::Client`
260
+ - `Blog.models` → Document 类
261
+ - `Blog.collections` → `client.database.collection_names`(探索,不造类)
262
+ - `Blog.config` → 该环境 config
263
+ - `Blog.q(doc)` → `command(doc)`
264
+ - `Blog.dump`:第一版**不实现**(属于砍掉的 mongodump)
265
+
266
+ ## 6. REPL 与展示
267
+
268
+ ### 6.1 Pry 命令
269
+
270
+ | 命令 | 行为 |
271
+ |------|------|
272
+ | `info [regexp]` | 匹配环境的:name、database、client 名、ping、URI(user/password 打码)、SSH active/local_port |
273
+ | `m` / `l` | 已加载模型表:Table Name(collection)、Model Class、Abbr。`-e` 环境正则。第一版不做 `-c` 列扫描、不做 `-f sql`(无 CREATE TABLE)。`-f md\|org\|terminal` 要做 |
274
+ | `show-sql` / `hide-sql` | 开关 command 日志。实现:订阅 `Mongo::Monitoring::COMMAND` 或提高 `Mongo::Logger` 并把输出写入 `App.log_io`。filter/command 打成可读 JSON,避免 BSON 二进制 |
275
+ | `reconnect` | 每环境:有 ssh 则 `ssh_proxy.reconnect`(仅网关死了才重建隧道);`ping?`(`hello`)为假才重建该环境 client。sandbox 中拒绝,提示先 `sandbox-quit` |
276
+ | `reconnect!` | 每环境:有 ssh 则 `ssh_proxy.reconnect!`(强制重建隧道,同一 local_port);然后强制按改写后的地址重建 client。sandbox 中同样拒绝 |
277
+ | `sandbox-enter` / `sandbox-quit` | 第 7 节 |
278
+ | `redefine` / `redef` | sandbox 中**拒绝**(与 `reconnect` 相同,提示先 `sandbox-quit`)。否则每环境 `redefine!`(只重载前缀模型文件,不重载全局 init.rb) |
279
+
280
+ `-S` 启动即 `show-sql`;`-w`/`-A` 把同一 `log_io` 接到文件。
281
+
282
+ ### 6.2 BSON 展示
283
+
284
+ `Presenter.scalar(value)`:
285
+
286
+ | 输入 | 输出 |
287
+ |------|------|
288
+ | `BSON::ObjectId` | 24 位 hex 字符串 |
289
+ | `Time` / `Date` | `YYYY-MM-DD HH:MM:SS`(本地) |
290
+ | `BSON::Binary` | `#<BSON::Binary type=.. size=n>` |
291
+ | `BSON::Decimal128` | `to_s` |
292
+ | `Hash` / `Array` | `as_json` 后的紧凑 JSON,超过 120 字符截断加 `…` |
293
+ | 其它 | `as_json` 或 `to_s` |
294
+
295
+ 文档 `t` / `v`:表头取**物化文档的 `attributes.keys`**(含未在模型里 `field` 声明的键、含 `_id`),不是只打 `fields` 声明。多行时表头取**第一行**的 keys;后续行缺的键留空,多出来的键本表不显示(不求并集,避免宽表爆炸)。值为 Presenter 后的标量。`_meta` 作为一列截断 JSON,不自动展开为多列。
296
+
297
+ 类方法 `Fund.t`(Arql 那种打表结构):第一版**不做**。结构探索用 `m` 和 `Blog.collections`。
298
+
299
+ `v` 返回值(给 Org Babel / 给 `t` 复用,对齐 Arql):
300
+
301
+ - 单个文档:`[keys, nil, values]`
302
+ - 文档/Hash 数组:`[keys, nil, *row_values]`
303
+
304
+ Criteria / View 的 `t`、`v`、`vd`:若用户未设 `limit`,物化前最多取 **500** 行。表下(或 `v` 不打印时仅在 `t`/`vd` 打印)固定一行:`showing N rows (capped at 500)`,其中 N 为实际取出数。 **不做** `count` / `estimated_document_count`(Mongo 上两者都可能不便宜或不准)。用户自己 `.limit(10)` 则按该 limit,不显示 cap 提示。
305
+
306
+ `write_csv` / `write_excel` **不**套 500 上限;README 注明应对大结果自己 `limit`。
307
+
308
+ ### 6.3 明确不做(第一版)
309
+
310
+ - 自动扫集合生成模型
311
+ - DDL(`create_table` / `add_column` 等)
312
+ - Ransack / `name_like`
313
+ - `mysqldump` / `to_insert_sql` / `mongoexport`
314
+ - YouPlot / Chartkick
315
+ - sandbox 提交(只有 rollback)
316
+ - YAML `model_names` / `rename_columns`
317
+ - 类方法 `Model.t` 打 schema(无固定列)
318
+
319
+ ## 7. Sandbox、错误处理
320
+
321
+ ### 7.1 Sandbox
322
+
323
+ `sandbox-enter` 分两阶段,全有或全无:
324
+
325
+ 1. **检查:** 对每个环境 `hello = client.database.command(hello: 1).documents.first`(或 driver 等价)。无 `setName` 且 `msg != "isdbgrid"` → 整体失败,打印「当前部署不支持事务(需要 replica set 或 mongos),未进入 sandbox」,**不开任何 session**。
326
+ 2. **开启:** 全部通过后,逐环境 `session = client.start_session`;`session.start_transaction`;把 session 绑到该环境的 **同一个** `Mongo::Client`(见下)。第 N 个失败则 abort 已成功的 1..N-1,整体拒绝。
327
+
328
+ 绑定方式(实现约束,不是用户 API):REPL 不能包在 `with_session` 块里。**禁止**只把 session 塞进全局 `Mongoid::Threaded.set_session`——多环境时后一个会盖掉前一个。必须 **按 Environment 的 `Mongo::Client` 注入 session**(对该环境 client 设默认 session,或包装该 client 使后续操作带 session)。规格要求:**该环境模型的 `save`/`update`/`delete`/`create` 走该环境事务;`Blog.client[:x].insert_one` 第一版不保证进事务**,README 写明。
329
+
330
+ `sandbox-quit`:每个已开启的 session `abort_transaction` + `end_session`;解开 per-client session 绑定;prompt 去掉 sandbox。没有 commit 命令。
331
+
332
+ `sandbox-enter` 时已在 sandbox 中 → 提示已在,不嵌套。
333
+
334
+ sandbox 期间 Pry 命令 `reconnect` / `reconnect!` / `redefine`:**拒绝**并提示先 `sandbox-quit`,不悄悄退出 sandbox。`Environment#redefine!` / `reconnect!` 在 sandbox 中同样 `raise`。
335
+
336
+ ### 7.2 错误
337
+
338
+ | 情况 | 行为 |
339
+ |------|------|
340
+ | `-e` 环境不在 YAML | 退出 1,列出已知键 |
341
+ | 无 `uri` 且无 `host`/`database` 足够信息 | 该环境失败,退出 1 |
342
+ | 有 `uri` 无 `database` 且 URI path 为空或 `/` | 退出 1,要求 `database:` |
343
+ | 模型文件语法/LoadError | 打印路径和异常,退出 1 |
344
+ | 连接/鉴权失败 | 打印打码 URI 与异常,退出 1 |
345
+ | 多环境调用全局 `q` | stderr 提示用 `Namespace.q`,返回 nil |
346
+ | `info` 密码 | 全部替换为 `*` |
347
+
348
+ 日志与 `info` 不得出现明文密码。打码:URI 中 `://user:pass@` → `://user:***@`。
349
+
350
+ ## 8. 示例模型约定(用户配置,不进 gem)
351
+
352
+ 第一版 gem **不** 携带业务模型。README 给最小示例,用户放 `~/.monq.d/blog.rb`:
353
+
354
+ - `_id` 均为 ObjectId,Mongoid 默认主键可用。
355
+ - 集合名若不是 Mongoid 对类名的 pluralize 结果,必须 `store_in collection:`。
356
+ - 关联可以是自然键查询方法,不是只能用 ObjectId `belongs_to`。
357
+ - 日期字段可以是 **String**,范围查询靠 `YYYY-MM` / `YYYY-MM-DD` 字典序。
358
+ - 动态键用 `field type: Hash`。
359
+ - 共用元数据可用 `embeds_one` + `store_as: '_meta'`。嵌入类不要 `store_in client:`。
360
+
361
+ 集成测试用 spec 内临时 Document 子类,不依赖用户的 `blog.rb`。
362
+
363
+ ## 9. 依赖与仓库布局
364
+
365
+ **Runtime:** `mongoid` ~> 9.0(Ruby >= 3.2 的脚手架约束)、`pry`、`pry-byebug`、`net-ssh-gateway`、`terminal-table`、`rainbow`、`caxlsx`、`roo`。`mongo` 由 Mongoid 带入。
366
+
367
+ **Dev:** 已有 `rspec`、`rake`、`irb`。
368
+
369
+ **不引入:** ActiveRecord、Ransack、kaminari、youplot、chartkick、arql。
370
+
371
+ ```
372
+ exe/monq
373
+ lib/monq.rb
374
+ lib/monq/cli.rb
375
+ lib/monq/app.rb
376
+ lib/monq/environment.rb
377
+ lib/monq/repl.rb
378
+ lib/monq/ssh_proxy.rb
379
+ lib/monq/table.rb
380
+ lib/monq/vd.rb
381
+ lib/monq/multi_io.rb
382
+ lib/monq/presenter.rb
383
+ lib/monq/commands/{info,models,reconnect,redefine,show_sql,sandbox}.rb
384
+ lib/monq/ext/{object,kernel,document,criteria,view,array,hash}.rb
385
+ spec/
386
+ docs/superpowers/specs/2026-10-24-monq-design.md
387
+ ```
388
+
389
+ `exe/` 需加入 gemspec 的 `git ls-files` 可见文件;当前脚手架 **没有** `exe/`,实现时创建 `exe/monq`。
390
+
391
+ ## 10. 测试
392
+
393
+ 1. **单元(默认 rake spec):** YAML 合并与 URI 优先;`database:` 覆盖 URI path;无 `-e` 时合成/失败(含「不要把 YAML `default:` 锚点当环境」);单环境 CLI 覆盖与 `ssh:` deep merge;多环境禁止 CLI 覆盖;同 namespace 两环境失败;client 名与 namespace 默认;前缀 `blog.dev` → `blog.rb`;缩写冲突;Presenter;`const_missing` 顺序;URI 打码;SSH 改写后只留一条 seed + `direct_connection` 且 **无** `replica_set`;`mongodb+srv`+ssh 失败;sandbox 多环境失败时回退已开 session;`redefine!` 后 `Mongoid.models` 无残留(含嵌入类)。命令模块在假 Environment 上的输出。不连网。
394
+ 2. **集成(有 `MONQ_TEST_URI` 且 `MONQ_TEST_DB` 才跑,否则 skip):** `hello` 成功;`collection_names` 非空;临时 `store_in collection: 'users'` 的模型 `count` >= 1;`where(name: ...).limit(2).to_a` 能过 Presenter/`Table`。URI 只来自环境变量。
395
+ 3. 不测 Pry TTY。不把 `db.example.com` 密码写进仓库或 CI 配置。
396
+
397
+ ## 11. 实现顺序(给后续 plan 用,不是本 spec 的范围膨胀)
398
+
399
+ 建议切片,便于单独 PR/提交:骨架 CLI+App+Environment 连接 → 模型 load 与 namespace → Repl 与展示 → Pry 命令 → SSH → sandbox → 导出/vd → 可选集成 spec。
400
+
401
+ 本文件不规定每个函数的代码,只规定行为与边界。
data/exe/monq ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "monq"
5
+
6
+ Monq::Cli.start
data/lib/monq/app.rb ADDED
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ class App
5
+ CONFIG_CANDIDATES = %w[~/.monq.d/init.yml ~/.monq.d/init.yaml ~/.monq.yml ~/.monq.yaml].freeze
6
+ INITIALIZER_CANDIDATES = %w[~/.monq.d/init.rb ~/.monq.rb].freeze
7
+
8
+ attr_reader :environments, :options
9
+
10
+ class << self
11
+ attr_accessor :instance, :log_io
12
+ end
13
+
14
+ def initialize(options)
15
+ @options = options
16
+ self.class.instance = self
17
+ @environments = build_environments
18
+ end
19
+
20
+ def run!
21
+ @environments.each(&:connect!)
22
+ load_initializer!
23
+ @environments.each(&:load_models!)
24
+ if @options.code && !@options.code.empty?
25
+ eval(@options.code, TOPLEVEL_BINDING, "-e")
26
+ elsif @options.args&.first
27
+ load(@options.args.first)
28
+ elsif !$stdin.isatty
29
+ eval($stdin.read, TOPLEVEL_BINDING, "stdin")
30
+ else
31
+ require "monq/repl"
32
+ Repl.new.start
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def build_environments
39
+ path = resolve_config_path
40
+ yaml = path ? Config.load_file(path) : {}
41
+ selected = Config.select_environments(yaml, @options.environments, @options)
42
+ model_dir = File.expand_path("~/.monq.d")
43
+ selected.each_with_index.map do |(name, conf), index|
44
+ Environment.new(name, conf, first: index.zero?, model_dir: model_dir)
45
+ end
46
+ end
47
+
48
+ def resolve_config_path
49
+ if config_file_set?
50
+ path = File.expand_path(@options.config_file)
51
+ raise Error, "Config file not found: #{@options.config_file}" unless File.file?(path)
52
+
53
+ return path
54
+ end
55
+
56
+ found = CONFIG_CANDIDATES.map { |candidate| File.expand_path(candidate) }.find { |path| File.file?(path) }
57
+ return found if found
58
+ return nil if Config.sufficient_cli?(@options)
59
+
60
+ raise Error, "No config file found. Create ~/.monq.d/init.yml or pass -e / --uri"
61
+ end
62
+
63
+ def config_file_set?
64
+ value = @options.respond_to?(:config_file) ? @options.config_file : nil
65
+ value && !value.empty?
66
+ end
67
+
68
+ def load_initializer!
69
+ if initializer_set?
70
+ path = File.expand_path(@options.initializer)
71
+ raise Error, "Initializer not found: #{@options.initializer}" unless File.file?(path)
72
+
73
+ load(path)
74
+ return
75
+ end
76
+
77
+ path = INITIALIZER_CANDIDATES.map { |candidate| File.expand_path(candidate) }.find { |candidate| File.file?(candidate) }
78
+ load(path) if path
79
+ end
80
+
81
+ def initializer_set?
82
+ value = @options.respond_to?(:initializer) ? @options.initializer : nil
83
+ value && !value.empty?
84
+ end
85
+ end
86
+ end
data/lib/monq/cli.rb ADDED
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "ostruct"
5
+
6
+ module Monq
7
+ class Cli
8
+ CONNECTION_KEYS = %i[uri host port database username password auth_source].freeze
9
+
10
+ class << self
11
+ def start
12
+ App.new(parse!(ARGV)).run!
13
+ end
14
+
15
+ def parse!(argv)
16
+ options = OpenStruct.new(babel_compatable: false, ssh: {})
17
+
18
+ parser = OptionParser.new do |opts|
19
+ opts.banner = <<~BANNER
20
+ Usage: monq [options] [ruby file]
21
+
22
+ If neither [ruby file] nor -E is given, and STDIN is a tty, a Pry REPL is launched.
23
+ Otherwise the ruby file, -E code, or STDIN is evaluated and no REPL is started.
24
+ BANNER
25
+
26
+ opts.on("-cCONFIG_FILE", "--conf=CONFIG_FILE", "Specify config file") do |config_file|
27
+ options.config_file = config_file
28
+ end
29
+
30
+ opts.on("-iINITIALIZER", "--initializer=INITIALIZER", "Specify initializer ruby file") do |initializer|
31
+ options.initializer = initializer
32
+ end
33
+
34
+ opts.on("-eENVIRON", "--env=ENVIRON", "Environments, separated by comma, plus, or colon") do |env_names|
35
+ options.environments = env_names.split(/[,\+:]/).reject(&:empty?)
36
+ end
37
+
38
+ opts.on("-ECODE", "--eval=CODE", "Evaluate CODE") do |code|
39
+ options.code = code
40
+ end
41
+
42
+ opts.on("-b", "--babel", "Enable compatibility for Org-Mode Babel") do
43
+ options.babel_compatable = true
44
+ end
45
+
46
+ opts.on("-S", "--show-sql", "Show Mongo commands on STDOUT") do
47
+ options.show_sql = true
48
+ end
49
+
50
+ opts.on("-wOUTPUT", "--write-sql=OUTPUT", "Write Mongo commands to OUTPUT") do |file|
51
+ options.write_sql = file
52
+ end
53
+
54
+ opts.on("-AOUTPUT", "--append-sql=OUTPUT", "Append Mongo commands to OUTPUT") do |file|
55
+ options.append_sql = file
56
+ end
57
+
58
+ opts.on("--uri=URI", "MongoDB connection URI") do |uri|
59
+ options.uri = uri
60
+ end
61
+
62
+ opts.on("--db-host=HOST", "Database host") do |host|
63
+ options.host = host
64
+ end
65
+
66
+ opts.on("--db-port=PORT", "Database port") do |port|
67
+ options.port = port.to_i
68
+ end
69
+
70
+ opts.on("--db-name=NAME", "Database name") do |name|
71
+ options.database = name
72
+ end
73
+
74
+ opts.on("--db-user=USER", "Database user") do |user|
75
+ options.username = user
76
+ end
77
+
78
+ opts.on("--db-password=PASSWORD", "Database password") do |password|
79
+ options.password = password
80
+ end
81
+
82
+ opts.on("--auth-source=SOURCE", "Authentication database") do |source|
83
+ options.auth_source = source
84
+ end
85
+
86
+ opts.on("--ssh-host=HOST", "SSH jump host") do |host|
87
+ options.ssh[:host] = host
88
+ end
89
+
90
+ opts.on("--ssh-port=PORT", "SSH port") do |port|
91
+ options.ssh[:port] = port.to_i
92
+ end
93
+
94
+ opts.on("--ssh-user=USER", "SSH user") do |user|
95
+ options.ssh[:user] = user
96
+ end
97
+
98
+ opts.on("--ssh-password=PASSWORD", "SSH password") do |password|
99
+ options.ssh[:password] = password
100
+ end
101
+
102
+ opts.on("--ssh-local-port=PORT", "Local forwarded port") do |port|
103
+ options.ssh[:local_port] = port.to_i
104
+ end
105
+
106
+ opts.on("-V", "--version", "Prints version") do
107
+ puts "Monq #{Monq::VERSION}"
108
+ exit
109
+ end
110
+
111
+ opts.on("--help", "Prints this help") do
112
+ puts opts
113
+ exit
114
+ end
115
+ end
116
+
117
+ remaining = argv.dup
118
+ parser.parse!(remaining)
119
+ options.args = remaining
120
+ reject_multi_env_connection!(options)
121
+ options
122
+ end
123
+
124
+ private
125
+
126
+ def reject_multi_env_connection!(options)
127
+ return unless options.environments&.size.to_i > 1
128
+ return unless connection_flag?(options)
129
+
130
+ raise Error, "Connection CLI flags are not allowed with multiple -e environments"
131
+ end
132
+
133
+ def connection_flag?(options)
134
+ CONNECTION_KEYS.any? do |key|
135
+ value = options.public_send(key)
136
+ value && !(value.respond_to?(:empty?) && value.empty?)
137
+ end || (options.ssh && !options.ssh.empty?)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ module Commands
5
+ module Info
6
+ module_function
7
+
8
+ def render(envs, regexp)
9
+ envs.select { |env| env.name =~ regexp }.map { |env| format_env(env) }.join
10
+ end
11
+
12
+ def format_env(env)
13
+ config = env.config || {}
14
+ database = config[:database] || config["database"]
15
+ uri = Config.mask_uri(config[:uri] || config["uri"])
16
+ proxy = env.ssh_proxy
17
+ ssh = if proxy
18
+ active = proxy.respond_to?(:active?) ? proxy.active? : false
19
+ port = proxy.respond_to?(:local_port) ? proxy.local_port : nil
20
+ "ssh: active=#{active} local_port=#{port}"
21
+ else
22
+ "ssh: none"
23
+ end
24
+
25
+ <<~INFO
26
+ #{env.name}
27
+ database: #{database}
28
+ client: #{env.client_name}
29
+ ping: #{env.ping?}
30
+ uri: #{uri}
31
+ #{ssh}
32
+ INFO
33
+ end
34
+
35
+ def register_pry_command!
36
+ return if Pry.commands["info"]
37
+
38
+ Pry.commands.block_command("info", "Show connection info for matching environments") do |pattern|
39
+ pattern = Regexp.new(pattern || ".*", Regexp::IGNORECASE)
40
+ output.puts Monq::Commands::Info.render(Monq::App.instance.environments, pattern)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end