@semics-tech/mongolite 1.2.0 → 1.4.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.
- package/README.md +91 -20
- package/dist/bin/mongolite-debug.d.ts +6 -1
- package/dist/bin/mongolite-debug.js +6 -113
- package/dist/bin/mongolite-debug.js.map +1 -1
- package/dist/bin/mongolite.d.ts +13 -0
- package/dist/bin/mongolite.js +172 -0
- package/dist/bin/mongolite.js.map +1 -0
- package/dist/cjs/bin/mongolite-debug.js +6 -149
- package/dist/cjs/bin/mongolite.js +207 -0
- package/dist/cjs/cli/discover.js +156 -0
- package/dist/cjs/cli/explorer.js +929 -0
- package/dist/cjs/cli/filter-builder.js +276 -0
- package/dist/cjs/cli/format.js +161 -0
- package/dist/cjs/cli/schema.js +211 -0
- package/dist/cjs/cli/ui.js +431 -0
- package/dist/cjs/collection.js +8 -1
- package/dist/cjs/cursors/findCursor.js +20 -8
- package/dist/cjs/index.js +108 -1
- package/dist/cjs/mongo-client.js +18 -1
- package/dist/cjs/server.js +164 -0
- package/dist/cjs/sync/commands.js +126 -0
- package/dist/cjs/sync/diff.js +174 -0
- package/dist/cjs/sync/httpSink.js +159 -0
- package/dist/cjs/sync/index.js +40 -0
- package/dist/cjs/sync/mongoExecutor.js +306 -0
- package/dist/cjs/sync/mongoSink.js +161 -0
- package/dist/cjs/sync/outbox.js +358 -0
- package/dist/cjs/sync/protocol.js +177 -0
- package/dist/cjs/sync/replicator.js +747 -0
- package/dist/cjs/sync/shadow.js +178 -0
- package/dist/cjs/sync/types.js +2 -0
- package/dist/cli/discover.d.ts +40 -0
- package/dist/cli/discover.js +146 -0
- package/dist/cli/discover.js.map +1 -0
- package/dist/cli/explorer.d.ts +59 -0
- package/dist/cli/explorer.js +923 -0
- package/dist/cli/explorer.js.map +1 -0
- package/dist/cli/filter-builder.d.ts +52 -0
- package/dist/cli/filter-builder.js +268 -0
- package/dist/cli/filter-builder.js.map +1 -0
- package/dist/cli/format.d.ts +29 -0
- package/dist/cli/format.js +153 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/schema.d.ts +58 -0
- package/dist/cli/schema.js +205 -0
- package/dist/cli/schema.js.map +1 -0
- package/dist/cli/ui.d.ts +71 -0
- package/dist/cli/ui.js +379 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.js.map +1 -1
- package/dist/collection.js +8 -1
- package/dist/collection.js.map +1 -1
- package/dist/cursors/findCursor.d.ts +12 -0
- package/dist/cursors/findCursor.js +20 -8
- package/dist/cursors/findCursor.js.map +1 -1
- package/dist/index.d.ts +68 -2
- package/dist/index.js +96 -1
- package/dist/index.js.map +1 -1
- package/dist/mongo-client.d.ts +13 -0
- package/dist/mongo-client.js +18 -1
- package/dist/mongo-client.js.map +1 -1
- package/dist/server.d.ts +97 -0
- package/dist/server.js +158 -0
- package/dist/server.js.map +1 -0
- package/dist/sync/commands.d.ts +49 -0
- package/dist/sync/commands.js +118 -0
- package/dist/sync/commands.js.map +1 -0
- package/dist/sync/diff.d.ts +55 -0
- package/dist/sync/diff.js +169 -0
- package/dist/sync/diff.js.map +1 -0
- package/dist/sync/httpSink.d.ts +93 -0
- package/dist/sync/httpSink.js +155 -0
- package/dist/sync/httpSink.js.map +1 -0
- package/dist/sync/index.d.ts +17 -0
- package/dist/sync/index.js +10 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/mongoExecutor.d.ts +37 -0
- package/dist/sync/mongoExecutor.js +302 -0
- package/dist/sync/mongoExecutor.js.map +1 -0
- package/dist/sync/mongoSink.d.ts +109 -0
- package/dist/sync/mongoSink.js +123 -0
- package/dist/sync/mongoSink.js.map +1 -0
- package/dist/sync/outbox.d.ts +120 -0
- package/dist/sync/outbox.js +355 -0
- package/dist/sync/outbox.js.map +1 -0
- package/dist/sync/protocol.d.ts +93 -0
- package/dist/sync/protocol.js +168 -0
- package/dist/sync/protocol.js.map +1 -0
- package/dist/sync/replicator.d.ts +177 -0
- package/dist/sync/replicator.js +744 -0
- package/dist/sync/replicator.js.map +1 -0
- package/dist/sync/shadow.d.ts +54 -0
- package/dist/sync/shadow.js +173 -0
- package/dist/sync/shadow.js.map +1 -0
- package/dist/sync/types.d.ts +388 -0
- package/dist/sync/types.js +2 -0
- package/dist/sync/types.js.map +1 -0
- package/package.json +16 -1
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Explorer = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* The guided MongoLite explorer.
|
|
9
|
+
*
|
|
10
|
+
* The flow is deliberately linear — pick a database, pick a collection, pick
|
|
11
|
+
* something to do — and every step offers real choices derived from the data
|
|
12
|
+
* rather than asking the user to type something they have to know already.
|
|
13
|
+
* MongoDB syntax stays available under "Advanced", and every guided query
|
|
14
|
+
* prints the filter it built, so the CLI teaches the syntax instead of hiding
|
|
15
|
+
* it.
|
|
16
|
+
*/
|
|
17
|
+
const node_fs_1 = require("node:fs");
|
|
18
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
19
|
+
const index_js_1 = require("../index.js");
|
|
20
|
+
const discover_js_1 = require("./discover.js");
|
|
21
|
+
const schema_js_1 = require("./schema.js");
|
|
22
|
+
const filter_builder_js_1 = require("./filter-builder.js");
|
|
23
|
+
const format_js_1 = require("./format.js");
|
|
24
|
+
const ui_js_1 = require("./ui.js");
|
|
25
|
+
class Explorer {
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
this.client = null;
|
|
29
|
+
this.dbPath = null;
|
|
30
|
+
this.collectionName = null;
|
|
31
|
+
this.schema = null;
|
|
32
|
+
this.lastSql = null;
|
|
33
|
+
this.lastQuery = null;
|
|
34
|
+
/**
|
|
35
|
+
* `-d`/`-c` are a starting point, not a preference. Once they have been
|
|
36
|
+
* honoured they are forgotten, otherwise "open a different database" would
|
|
37
|
+
* take you straight back to the one on the command line.
|
|
38
|
+
*/
|
|
39
|
+
this.startingDatabaseUsed = false;
|
|
40
|
+
this.startingCollectionUsed = false;
|
|
41
|
+
this.sampleSize = options.sampleSize ?? 200;
|
|
42
|
+
this.pageSize = options.pageSize ?? 20;
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------- lifecycle
|
|
45
|
+
async run() {
|
|
46
|
+
(0, ui_js_1.info)(`${ui_js_1.style.bold('MongoLite')} ${ui_js_1.style.grey('· interactive explorer')}`);
|
|
47
|
+
(0, ui_js_1.info)(ui_js_1.style.grey('Ctrl+C to quit at any point.\n'));
|
|
48
|
+
try {
|
|
49
|
+
for (;;) {
|
|
50
|
+
if (!this.client) {
|
|
51
|
+
const connected = await this.chooseDatabase();
|
|
52
|
+
if (!connected)
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!this.collectionName) {
|
|
56
|
+
const chosen = await this.chooseCollection();
|
|
57
|
+
if (chosen === 'quit')
|
|
58
|
+
return;
|
|
59
|
+
if (chosen === 'switch-database') {
|
|
60
|
+
await this.disconnect();
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const outcome = await this.collectionMenu();
|
|
65
|
+
if (outcome === 'quit')
|
|
66
|
+
return;
|
|
67
|
+
if (outcome === 'switch-collection') {
|
|
68
|
+
this.collectionName = null;
|
|
69
|
+
this.schema = null;
|
|
70
|
+
}
|
|
71
|
+
if (outcome === 'switch-database') {
|
|
72
|
+
await this.disconnect();
|
|
73
|
+
this.collectionName = null;
|
|
74
|
+
this.schema = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await this.disconnect();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async disconnect() {
|
|
83
|
+
if (this.client) {
|
|
84
|
+
try {
|
|
85
|
+
await this.client.close();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Closing a database we may never have opened is not worth reporting.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.client = null;
|
|
92
|
+
this.dbPath = null;
|
|
93
|
+
}
|
|
94
|
+
// ------------------------------------------------------------ step 1: file
|
|
95
|
+
async chooseDatabase() {
|
|
96
|
+
// An explicit -d wins the first time round, as long as it is usable.
|
|
97
|
+
if (this.options.database && !this.startingDatabaseUsed) {
|
|
98
|
+
this.startingDatabaseUsed = true;
|
|
99
|
+
const resolved = (0, discover_js_1.resolveDatabasePath)(this.options.database);
|
|
100
|
+
if ((0, discover_js_1.databaseExists)(resolved)) {
|
|
101
|
+
if (await this.connect(resolved))
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
(0, ui_js_1.warn)(`No database at ${ui_js_1.style.bold(resolved)} — pick one below.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (;;) {
|
|
109
|
+
const candidates = (0, discover_js_1.findDatabaseFiles)(process.cwd());
|
|
110
|
+
const choices = candidates.map((candidate) => ({
|
|
111
|
+
label: candidate.relativePath,
|
|
112
|
+
value: candidate.path,
|
|
113
|
+
hint: `${(0, discover_js_1.formatBytes)(candidate.sizeBytes)} · ${(0, discover_js_1.formatRelativeTime)(candidate.modified)}`,
|
|
114
|
+
}));
|
|
115
|
+
if (choices.length === 0) {
|
|
116
|
+
(0, ui_js_1.warn)('No .db/.sqlite files found in this directory.');
|
|
117
|
+
}
|
|
118
|
+
choices.push({ label: 'Enter a path manually…', value: '__manual__' });
|
|
119
|
+
choices.push({ label: 'Quit', value: '__quit__' });
|
|
120
|
+
let picked;
|
|
121
|
+
try {
|
|
122
|
+
picked = await (0, ui_js_1.select)({
|
|
123
|
+
message: 'Which database do you want to open?',
|
|
124
|
+
choices,
|
|
125
|
+
hint: ' ↑/↓ move · type to filter · enter to open',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
130
|
+
return false;
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
if (picked === '__quit__')
|
|
134
|
+
return false;
|
|
135
|
+
const target = picked === '__manual__'
|
|
136
|
+
? (0, discover_js_1.resolveDatabasePath)(await (0, ui_js_1.text)({
|
|
137
|
+
message: 'Path to the database file',
|
|
138
|
+
defaultValue: node_path_1.default.join(process.cwd(), 'mongolite.db'),
|
|
139
|
+
}))
|
|
140
|
+
: picked;
|
|
141
|
+
if (!(0, discover_js_1.databaseExists)(target)) {
|
|
142
|
+
(0, ui_js_1.error)(`No file at ${target}`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (await this.connect(target))
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async connect(dbPath) {
|
|
150
|
+
try {
|
|
151
|
+
const client = new index_js_1.MongoLite(dbPath, { verbose: this.options.verbose });
|
|
152
|
+
await client.connect();
|
|
153
|
+
this.client = client;
|
|
154
|
+
this.dbPath = dbPath;
|
|
155
|
+
(0, ui_js_1.success)(`Opened ${ui_js_1.style.bold(node_path_1.default.relative(process.cwd(), dbPath) || dbPath)}`);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
(0, ui_js_1.error)(`Could not open that database: ${message(err)}`);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// ------------------------------------------------------ step 2: collection
|
|
164
|
+
async chooseCollection() {
|
|
165
|
+
const client = this.requireClient();
|
|
166
|
+
let collections;
|
|
167
|
+
try {
|
|
168
|
+
collections = await (0, discover_js_1.listCollectionInfo)(client.database);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
(0, ui_js_1.error)(`Could not read the collections: ${message(err)}`);
|
|
172
|
+
return 'switch-database';
|
|
173
|
+
}
|
|
174
|
+
const usable = collections.filter((collection) => collection.isMongoLite);
|
|
175
|
+
const foreign = collections.filter((collection) => !collection.isMongoLite);
|
|
176
|
+
if (usable.length === 0) {
|
|
177
|
+
(0, ui_js_1.warn)('This database has no MongoLite collections.');
|
|
178
|
+
if (foreign.length > 0) {
|
|
179
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(` It does contain ${foreign.length} other table(s): ${foreign
|
|
180
|
+
.map((table) => table.name)
|
|
181
|
+
.slice(0, 5)
|
|
182
|
+
.join(', ')}`));
|
|
183
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' You can still inspect them with the raw SQL option.'));
|
|
184
|
+
}
|
|
185
|
+
const next = await (0, ui_js_1.select)({
|
|
186
|
+
message: 'What now?',
|
|
187
|
+
choices: [
|
|
188
|
+
{ label: 'Open a different database', value: 'switch-database' },
|
|
189
|
+
{ label: 'Run raw SQL against this one', value: 'sql' },
|
|
190
|
+
{ label: 'Quit', value: 'quit' },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
if (next === 'sql') {
|
|
194
|
+
await this.runRawSql();
|
|
195
|
+
return this.chooseCollection();
|
|
196
|
+
}
|
|
197
|
+
return next;
|
|
198
|
+
}
|
|
199
|
+
// An explicit -c only counts the first time, and only if it is really there.
|
|
200
|
+
if (this.options.collection && !this.startingCollectionUsed) {
|
|
201
|
+
this.startingCollectionUsed = true;
|
|
202
|
+
const match = usable.find((collection) => collection.name === this.options.collection);
|
|
203
|
+
if (match) {
|
|
204
|
+
await this.useCollection(match.name);
|
|
205
|
+
return 'ok';
|
|
206
|
+
}
|
|
207
|
+
(0, ui_js_1.warn)(`No collection named "${this.options.collection}" in this database.`);
|
|
208
|
+
}
|
|
209
|
+
const choices = usable.map((collection) => ({
|
|
210
|
+
label: collection.name,
|
|
211
|
+
value: collection.name,
|
|
212
|
+
hint: `${collection.count.toLocaleString()} document${collection.count === 1 ? '' : 's'}`,
|
|
213
|
+
}));
|
|
214
|
+
choices.push({ label: 'Open a different database', value: '__switch__' });
|
|
215
|
+
choices.push({ label: 'Quit', value: '__quit__' });
|
|
216
|
+
let picked;
|
|
217
|
+
try {
|
|
218
|
+
picked = await (0, ui_js_1.select)({ message: 'Which collection?', choices });
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
222
|
+
return 'switch-database';
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
if (picked === '__quit__')
|
|
226
|
+
return 'quit';
|
|
227
|
+
if (picked === '__switch__')
|
|
228
|
+
return 'switch-database';
|
|
229
|
+
await this.useCollection(picked);
|
|
230
|
+
return 'ok';
|
|
231
|
+
}
|
|
232
|
+
async useCollection(name) {
|
|
233
|
+
this.collectionName = name;
|
|
234
|
+
this.lastQuery = null;
|
|
235
|
+
const collection = this.collection();
|
|
236
|
+
try {
|
|
237
|
+
const sample = (await collection.find({}).limit(this.sampleSize).toArray());
|
|
238
|
+
this.schema = (0, schema_js_1.inferSchema)(sample);
|
|
239
|
+
const total = await collection.countDocuments({});
|
|
240
|
+
const fieldCount = this.schema.fields.length;
|
|
241
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(` ${total.toLocaleString()} documents · ${fieldCount} field${fieldCount === 1 ? '' : 's'} inferred from ${sample.length.toLocaleString()} sampled`));
|
|
242
|
+
if (total === 0) {
|
|
243
|
+
(0, ui_js_1.warn)('This collection is empty, so there is not much to explore yet.');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
(0, ui_js_1.warn)(`Could not sample "${name}": ${message(err)}`);
|
|
248
|
+
this.schema = { sampled: 0, fields: [] };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// -------------------------------------------------------- step 3: what now
|
|
252
|
+
async collectionMenu() {
|
|
253
|
+
const choices = [
|
|
254
|
+
{ label: 'Browse documents', value: 'browse', hint: '— page through everything' },
|
|
255
|
+
{ label: 'Find documents', value: 'find', hint: '— build a filter step by step' },
|
|
256
|
+
{ label: 'Count documents', value: 'count', hint: '— how many match a filter' },
|
|
257
|
+
{ label: 'List the values in a field', value: 'distinct', hint: '— distinct values' },
|
|
258
|
+
{ label: 'Count documents by field', value: 'group', hint: '— e.g. orders per status' },
|
|
259
|
+
{ label: 'Show the fields in this collection', value: 'schema' },
|
|
260
|
+
{ label: 'Show indexes', value: 'indexes' },
|
|
261
|
+
{ label: 'Advanced', value: 'json-filter', separator: true },
|
|
262
|
+
{ label: 'Write a MongoDB filter yourself', value: 'json-filter', hint: '— JSON' },
|
|
263
|
+
{ label: 'Run raw SQL', value: 'sql' },
|
|
264
|
+
{ label: 'Show the SQL for the last query', value: 'last-sql' },
|
|
265
|
+
{ label: ' ', value: 'quit', separator: true },
|
|
266
|
+
{ label: 'Switch collection', value: 'switch-collection' },
|
|
267
|
+
{ label: 'Open a different database', value: 'switch-database' },
|
|
268
|
+
{ label: 'Quit', value: 'quit' },
|
|
269
|
+
];
|
|
270
|
+
let action;
|
|
271
|
+
try {
|
|
272
|
+
action = await (0, ui_js_1.select)({
|
|
273
|
+
message: `${this.collectionName} — what would you like to do?`,
|
|
274
|
+
choices,
|
|
275
|
+
pageSize: 16,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
280
|
+
return 'switch-collection';
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
switch (action) {
|
|
285
|
+
case 'browse':
|
|
286
|
+
await this.runQuery({ filter: {}, sort: null, description: 'all documents' });
|
|
287
|
+
break;
|
|
288
|
+
case 'find':
|
|
289
|
+
await this.guidedFind();
|
|
290
|
+
break;
|
|
291
|
+
case 'count':
|
|
292
|
+
await this.countFlow();
|
|
293
|
+
break;
|
|
294
|
+
case 'distinct':
|
|
295
|
+
await this.distinctFlow();
|
|
296
|
+
break;
|
|
297
|
+
case 'group':
|
|
298
|
+
await this.groupFlow();
|
|
299
|
+
break;
|
|
300
|
+
case 'schema':
|
|
301
|
+
await this.showSchema();
|
|
302
|
+
break;
|
|
303
|
+
case 'indexes':
|
|
304
|
+
await this.showIndexes();
|
|
305
|
+
break;
|
|
306
|
+
case 'json-filter':
|
|
307
|
+
await this.jsonFilterFlow();
|
|
308
|
+
break;
|
|
309
|
+
case 'sql':
|
|
310
|
+
await this.runRawSql();
|
|
311
|
+
break;
|
|
312
|
+
case 'last-sql':
|
|
313
|
+
this.showLastSql();
|
|
314
|
+
await (0, ui_js_1.pause)();
|
|
315
|
+
break;
|
|
316
|
+
case 'switch-collection':
|
|
317
|
+
return 'switch-collection';
|
|
318
|
+
case 'switch-database':
|
|
319
|
+
return 'switch-database';
|
|
320
|
+
case 'quit':
|
|
321
|
+
return 'quit';
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
if (!(err instanceof ui_js_1.PromptCancelledError)) {
|
|
326
|
+
(0, ui_js_1.error)(message(err));
|
|
327
|
+
await (0, ui_js_1.pause)();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return 'stay';
|
|
331
|
+
}
|
|
332
|
+
// ------------------------------------------------------------ guided query
|
|
333
|
+
async guidedFind() {
|
|
334
|
+
const conditions = await this.buildConditions();
|
|
335
|
+
if (conditions === null)
|
|
336
|
+
return;
|
|
337
|
+
let filter = {};
|
|
338
|
+
let description = 'all documents';
|
|
339
|
+
if (conditions.length > 0) {
|
|
340
|
+
const mode = conditions.length === 1
|
|
341
|
+
? 'all'
|
|
342
|
+
: await (0, ui_js_1.select)({
|
|
343
|
+
message: 'Should documents match…',
|
|
344
|
+
choices: [
|
|
345
|
+
{ label: 'all of these conditions', value: 'all', hint: '— AND' },
|
|
346
|
+
{ label: 'any of these conditions', value: 'any', hint: '— OR' },
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
filter = (0, filter_builder_js_1.combineConditions)(conditions, mode);
|
|
350
|
+
description = conditions
|
|
351
|
+
.map((condition) => (0, filter_builder_js_1.describeCondition)(condition))
|
|
352
|
+
.join(mode === 'all' ? ' and ' : ' or ');
|
|
353
|
+
}
|
|
354
|
+
const sort = await this.chooseSort();
|
|
355
|
+
await this.runQuery({ filter, sort, description });
|
|
356
|
+
}
|
|
357
|
+
/** Collect conditions until the user is done. Returns null when cancelled. */
|
|
358
|
+
async buildConditions(existing = []) {
|
|
359
|
+
const conditions = [...existing];
|
|
360
|
+
for (;;) {
|
|
361
|
+
if (conditions.length > 0) {
|
|
362
|
+
(0, ui_js_1.heading)('Conditions so far');
|
|
363
|
+
conditions.forEach((condition, index) => {
|
|
364
|
+
(0, ui_js_1.info)(` ${index + 1}. ${(0, filter_builder_js_1.describeCondition)(condition)}`);
|
|
365
|
+
});
|
|
366
|
+
(0, ui_js_1.info)('');
|
|
367
|
+
}
|
|
368
|
+
const choices = [
|
|
369
|
+
{
|
|
370
|
+
label: conditions.length === 0 ? 'Add a condition' : 'Add another condition',
|
|
371
|
+
value: 'add',
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
label: conditions.length === 0 ? 'Run without any filter' : 'Run the query',
|
|
375
|
+
value: 'run',
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
if (conditions.length > 0)
|
|
379
|
+
choices.push({ label: 'Remove a condition', value: 'remove' });
|
|
380
|
+
choices.push({ label: 'Cancel', value: 'cancel' });
|
|
381
|
+
let step;
|
|
382
|
+
try {
|
|
383
|
+
step = await (0, ui_js_1.select)({ message: 'Build your query', choices });
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
387
|
+
return null;
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
if (step === 'cancel')
|
|
391
|
+
return null;
|
|
392
|
+
if (step === 'run')
|
|
393
|
+
return conditions;
|
|
394
|
+
if (step === 'remove') {
|
|
395
|
+
const index = await (0, ui_js_1.select)({
|
|
396
|
+
message: 'Remove which condition?',
|
|
397
|
+
choices: conditions.map((condition, position) => ({
|
|
398
|
+
label: (0, filter_builder_js_1.describeCondition)(condition),
|
|
399
|
+
value: position,
|
|
400
|
+
})),
|
|
401
|
+
});
|
|
402
|
+
conditions.splice(index, 1);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const condition = await this.buildOneCondition();
|
|
406
|
+
if (condition)
|
|
407
|
+
conditions.push(condition);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async buildOneCondition() {
|
|
411
|
+
const schema = this.schema;
|
|
412
|
+
if (!schema || schema.fields.length === 0) {
|
|
413
|
+
(0, ui_js_1.warn)('No fields were found in this collection, so there is nothing to filter on.');
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
let field;
|
|
417
|
+
try {
|
|
418
|
+
field = await (0, ui_js_1.select)({
|
|
419
|
+
message: 'Which field?',
|
|
420
|
+
choices: [
|
|
421
|
+
...schema.fields.map((candidate) => ({
|
|
422
|
+
label: candidate.path,
|
|
423
|
+
value: candidate,
|
|
424
|
+
hint: (0, schema_js_1.describeField)(candidate),
|
|
425
|
+
})),
|
|
426
|
+
{ label: 'Type a field name myself…', value: '__custom__' },
|
|
427
|
+
],
|
|
428
|
+
pageSize: 14,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
433
|
+
return null;
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
const resolved = field === '__custom__'
|
|
437
|
+
? {
|
|
438
|
+
path: await (0, ui_js_1.text)({
|
|
439
|
+
message: 'Field name (dot notation for nested fields, e.g. address.city)',
|
|
440
|
+
}),
|
|
441
|
+
types: ['string'],
|
|
442
|
+
primaryType: 'string',
|
|
443
|
+
count: 0,
|
|
444
|
+
presence: 0,
|
|
445
|
+
examples: [],
|
|
446
|
+
lowCardinality: false,
|
|
447
|
+
}
|
|
448
|
+
: field;
|
|
449
|
+
const operators = (0, filter_builder_js_1.operatorsFor)(resolved);
|
|
450
|
+
let operatorId;
|
|
451
|
+
try {
|
|
452
|
+
operatorId = await (0, ui_js_1.select)({
|
|
453
|
+
message: `${resolved.path} …`,
|
|
454
|
+
choices: operators.map((operator) => ({
|
|
455
|
+
label: operator.label,
|
|
456
|
+
value: operator.id,
|
|
457
|
+
hint: operator.hint ? `— ${operator.hint}` : undefined,
|
|
458
|
+
})),
|
|
459
|
+
pageSize: 14,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
464
|
+
return null;
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
const operator = operators.find((candidate) => candidate.id === operatorId);
|
|
468
|
+
if (!operator)
|
|
469
|
+
return null;
|
|
470
|
+
// Values are parsed as the element type for array fields, so
|
|
471
|
+
// "tags contains work" compares against the strings inside the array.
|
|
472
|
+
const valueType = resolved.primaryType === 'array'
|
|
473
|
+
? (resolved.elementTypes?.find((type) => type !== 'null') ?? 'string')
|
|
474
|
+
: resolved.primaryType;
|
|
475
|
+
const values = [];
|
|
476
|
+
try {
|
|
477
|
+
if (operator.arity === 'one') {
|
|
478
|
+
values.push(await this.askForValue(resolved, valueType, operator.valuePrompt ?? 'Value'));
|
|
479
|
+
}
|
|
480
|
+
else if (operator.arity === 'two') {
|
|
481
|
+
values.push(await this.askForValue(resolved, valueType, operator.valuePrompt ?? 'From'));
|
|
482
|
+
values.push(await this.askForValue(resolved, valueType, operator.secondValuePrompt ?? 'To'));
|
|
483
|
+
}
|
|
484
|
+
else if (operator.arity === 'many') {
|
|
485
|
+
const raw = await (0, ui_js_1.text)({
|
|
486
|
+
message: operator.valuePrompt ?? 'Values (comma separated)',
|
|
487
|
+
validate: (input) => {
|
|
488
|
+
try {
|
|
489
|
+
(0, filter_builder_js_1.parseValueList)(input, valueType);
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
return message(err);
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
values.push(...(0, filter_builder_js_1.parseValueList)(raw, valueType));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
502
|
+
return null;
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
return { field: resolved.path, operator: operatorId, values, fieldType: valueType };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Ask for one value. When the field has few distinct values in the sample we
|
|
509
|
+
* offer them as a menu — no typing, no guessing at spelling or casing.
|
|
510
|
+
*/
|
|
511
|
+
async askForValue(field, valueType, prompt) {
|
|
512
|
+
if (field.lowCardinality && field.distinctValues && field.distinctValues.length > 1) {
|
|
513
|
+
const picked = await (0, ui_js_1.select)({
|
|
514
|
+
message: prompt,
|
|
515
|
+
choices: [
|
|
516
|
+
...field.distinctValues.map((value) => ({
|
|
517
|
+
label: (0, format_js_1.formatCell)(value),
|
|
518
|
+
value,
|
|
519
|
+
})),
|
|
520
|
+
{ label: 'Something else…', value: '__other__' },
|
|
521
|
+
],
|
|
522
|
+
hint: ui_js_1.style.grey(` values seen in ${field.count} sampled document(s)`),
|
|
523
|
+
pageSize: 14,
|
|
524
|
+
});
|
|
525
|
+
if (picked !== '__other__')
|
|
526
|
+
return picked;
|
|
527
|
+
}
|
|
528
|
+
const raw = await (0, ui_js_1.text)({
|
|
529
|
+
message: prompt,
|
|
530
|
+
hint: field.examples.length
|
|
531
|
+
? ui_js_1.style.grey(` examples: ${field.examples.slice(0, 3).map(format_js_1.formatCell).join(', ')}`)
|
|
532
|
+
: undefined,
|
|
533
|
+
validate: (input) => {
|
|
534
|
+
try {
|
|
535
|
+
(0, filter_builder_js_1.parseValue)(input, valueType);
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
return message(err);
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
return (0, filter_builder_js_1.parseValue)(raw, valueType);
|
|
544
|
+
}
|
|
545
|
+
async chooseSort() {
|
|
546
|
+
const schema = this.schema;
|
|
547
|
+
if (!schema || schema.fields.length === 0)
|
|
548
|
+
return null;
|
|
549
|
+
const sortable = schema.fields.filter((field) => ['string', 'number', 'date', 'boolean'].includes(field.primaryType));
|
|
550
|
+
if (sortable.length === 0)
|
|
551
|
+
return null;
|
|
552
|
+
const field = await (0, ui_js_1.select)({
|
|
553
|
+
message: 'Sort the results by…',
|
|
554
|
+
choices: [
|
|
555
|
+
{ label: "Don't sort", value: null },
|
|
556
|
+
...sortable.map((candidate) => ({
|
|
557
|
+
label: candidate.path,
|
|
558
|
+
value: candidate.path,
|
|
559
|
+
hint: (0, schema_js_1.describeField)(candidate),
|
|
560
|
+
})),
|
|
561
|
+
],
|
|
562
|
+
pageSize: 12,
|
|
563
|
+
});
|
|
564
|
+
if (!field)
|
|
565
|
+
return null;
|
|
566
|
+
const direction = await (0, ui_js_1.select)({
|
|
567
|
+
message: `Sort ${field}…`,
|
|
568
|
+
choices: [
|
|
569
|
+
{ label: 'ascending', value: 1, hint: '— A→Z, smallest first, oldest first' },
|
|
570
|
+
{ label: 'descending', value: -1, hint: '— Z→A, largest first, newest first' },
|
|
571
|
+
],
|
|
572
|
+
});
|
|
573
|
+
return { [field]: direction };
|
|
574
|
+
}
|
|
575
|
+
// -------------------------------------------------------------- run & view
|
|
576
|
+
async runQuery(query) {
|
|
577
|
+
this.lastQuery = query;
|
|
578
|
+
const collection = this.collection();
|
|
579
|
+
let total;
|
|
580
|
+
try {
|
|
581
|
+
total = await collection.countDocuments(query.filter);
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
(0, ui_js_1.error)(`That query could not run: ${message(err)}`);
|
|
585
|
+
await (0, ui_js_1.pause)();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
(0, ui_js_1.heading)('Query');
|
|
589
|
+
(0, ui_js_1.info)(` ${ui_js_1.style.grey('matching')} ${query.description}`);
|
|
590
|
+
(0, ui_js_1.info)(` ${ui_js_1.style.grey('filter')} ${JSON.stringify(query.filter)}`);
|
|
591
|
+
if (query.sort)
|
|
592
|
+
(0, ui_js_1.info)(` ${ui_js_1.style.grey('sort')} ${JSON.stringify(query.sort)}`);
|
|
593
|
+
(0, ui_js_1.info)(` ${ui_js_1.style.grey('matches')} ${ui_js_1.style.bold(total.toLocaleString())} document(s)`);
|
|
594
|
+
if (total === 0) {
|
|
595
|
+
(0, ui_js_1.info)('');
|
|
596
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' Nothing matched. Try a looser condition, or check the field spelling.'));
|
|
597
|
+
await (0, ui_js_1.pause)();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
let columns = this.schema ? (0, schema_js_1.defaultColumns)(this.schema) : ['_id'];
|
|
601
|
+
let offset = 0;
|
|
602
|
+
for (;;) {
|
|
603
|
+
const cursor = collection.find(query.filter).skip(offset).limit(this.pageSize);
|
|
604
|
+
if (query.sort)
|
|
605
|
+
cursor.sort(query.sort);
|
|
606
|
+
this.lastSql = cursor.toSQL();
|
|
607
|
+
let docs;
|
|
608
|
+
try {
|
|
609
|
+
docs = (await cursor.toArray());
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
(0, ui_js_1.error)(`Could not fetch results: ${message(err)}`);
|
|
613
|
+
await (0, ui_js_1.pause)();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (docs.length === 0 && offset > 0) {
|
|
617
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' No more documents.'));
|
|
618
|
+
offset = Math.max(0, offset - this.pageSize);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
(0, ui_js_1.info)('');
|
|
622
|
+
(0, ui_js_1.info)((0, format_js_1.renderTable)(docs, { columns, startIndex: offset }));
|
|
623
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(` showing ${offset + 1}-${offset + docs.length} of ${total.toLocaleString()}`));
|
|
624
|
+
(0, ui_js_1.info)('');
|
|
625
|
+
const choices = [];
|
|
626
|
+
if (offset + docs.length < total) {
|
|
627
|
+
choices.push({ label: `Show the next ${this.pageSize}`, value: 'next' });
|
|
628
|
+
}
|
|
629
|
+
if (offset > 0)
|
|
630
|
+
choices.push({ label: 'Show the previous page', value: 'prev' });
|
|
631
|
+
choices.push({ label: 'View one document in full', value: 'view' });
|
|
632
|
+
choices.push({ label: 'Choose which columns to show', value: 'columns' });
|
|
633
|
+
choices.push({ label: 'Save these results to a JSON file', value: 'export' });
|
|
634
|
+
choices.push({ label: 'Show the SQL this ran', value: 'sql' });
|
|
635
|
+
choices.push({ label: 'Change the filter', value: 'refine' });
|
|
636
|
+
choices.push({ label: 'Back to the menu', value: 'done' });
|
|
637
|
+
let action;
|
|
638
|
+
try {
|
|
639
|
+
action = await (0, ui_js_1.select)({ message: 'Results', choices, pageSize: 12 });
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
643
|
+
return;
|
|
644
|
+
throw err;
|
|
645
|
+
}
|
|
646
|
+
switch (action) {
|
|
647
|
+
case 'next':
|
|
648
|
+
offset += this.pageSize;
|
|
649
|
+
break;
|
|
650
|
+
case 'prev':
|
|
651
|
+
offset = Math.max(0, offset - this.pageSize);
|
|
652
|
+
break;
|
|
653
|
+
case 'view': {
|
|
654
|
+
const doc = await (0, ui_js_1.select)({
|
|
655
|
+
message: 'Which document?',
|
|
656
|
+
choices: docs.map((candidate, index) => ({
|
|
657
|
+
label: `${offset + index + 1}. ${(0, format_js_1.formatCell)(candidate._id)}`,
|
|
658
|
+
value: candidate,
|
|
659
|
+
hint: columns
|
|
660
|
+
.filter((column) => column !== '_id')
|
|
661
|
+
.slice(0, 2)
|
|
662
|
+
.map((column) => (0, format_js_1.formatCell)((0, schema_js_1.getPath)(candidate, column)))
|
|
663
|
+
.join(' · '),
|
|
664
|
+
})),
|
|
665
|
+
pageSize: 12,
|
|
666
|
+
});
|
|
667
|
+
(0, ui_js_1.heading)('Document');
|
|
668
|
+
(0, ui_js_1.info)((0, format_js_1.renderDocument)(doc));
|
|
669
|
+
await (0, ui_js_1.pause)();
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
case 'columns':
|
|
673
|
+
columns = await this.chooseColumns(columns);
|
|
674
|
+
break;
|
|
675
|
+
case 'export':
|
|
676
|
+
await this.exportDocuments(query, total);
|
|
677
|
+
break;
|
|
678
|
+
case 'sql':
|
|
679
|
+
this.showLastSql();
|
|
680
|
+
await (0, ui_js_1.pause)();
|
|
681
|
+
break;
|
|
682
|
+
case 'refine':
|
|
683
|
+
return this.guidedFind();
|
|
684
|
+
case 'done':
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async chooseColumns(current) {
|
|
690
|
+
const schema = this.schema;
|
|
691
|
+
if (!schema)
|
|
692
|
+
return current;
|
|
693
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(` Current columns: ${current.join(', ')}`));
|
|
694
|
+
// No defaultValue: `text` substitutes it for a blank line, which would make
|
|
695
|
+
// "blank for all fields" mean "keep what you already have".
|
|
696
|
+
const raw = await (0, ui_js_1.text)({
|
|
697
|
+
message: 'Columns to show (comma separated, blank for all fields)',
|
|
698
|
+
allowEmpty: true,
|
|
699
|
+
hint: ui_js_1.style.grey(` available: ${schema.fields.map((field) => field.path).join(', ')}`),
|
|
700
|
+
});
|
|
701
|
+
if (raw.trim() === '')
|
|
702
|
+
return schema.fields.map((field) => field.path);
|
|
703
|
+
const columns = raw
|
|
704
|
+
.split(',')
|
|
705
|
+
.map((column) => column.trim())
|
|
706
|
+
.filter((column) => column !== '');
|
|
707
|
+
return columns.length > 0 ? columns : current;
|
|
708
|
+
}
|
|
709
|
+
async exportDocuments(query, total) {
|
|
710
|
+
const limitRaw = await (0, ui_js_1.text)({
|
|
711
|
+
message: `How many documents? (up to ${total})`,
|
|
712
|
+
defaultValue: String(Math.min(total, 1000)),
|
|
713
|
+
validate: (input) => Number.isInteger(Number(input)) && Number(input) > 0 ? undefined : 'Enter a whole number.',
|
|
714
|
+
});
|
|
715
|
+
const limit = Number(limitRaw);
|
|
716
|
+
const file = await (0, ui_js_1.text)({
|
|
717
|
+
message: 'Save to which file?',
|
|
718
|
+
defaultValue: node_path_1.default.join(process.cwd(), `${this.collectionName}-export.json`),
|
|
719
|
+
});
|
|
720
|
+
const cursor = this.collection().find(query.filter).limit(limit);
|
|
721
|
+
if (query.sort)
|
|
722
|
+
cursor.sort(query.sort);
|
|
723
|
+
const docs = await cursor.toArray();
|
|
724
|
+
const target = node_path_1.default.resolve(file);
|
|
725
|
+
if ((0, node_fs_1.existsSync)(target)) {
|
|
726
|
+
const overwrite = await (0, ui_js_1.confirm)(`${target} already exists. Overwrite it?`, false);
|
|
727
|
+
if (!overwrite) {
|
|
728
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' Not saved.'));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
(0, node_fs_1.writeFileSync)(target, `${JSON.stringify(docs, null, 2)}\n`, 'utf8');
|
|
733
|
+
(0, ui_js_1.success)(`Wrote ${docs.length.toLocaleString()} document(s) to ${target}`);
|
|
734
|
+
}
|
|
735
|
+
// ------------------------------------------------------------ other actions
|
|
736
|
+
async countFlow() {
|
|
737
|
+
const conditions = await this.buildConditions();
|
|
738
|
+
if (conditions === null)
|
|
739
|
+
return;
|
|
740
|
+
const mode = conditions.length > 1
|
|
741
|
+
? await (0, ui_js_1.select)({
|
|
742
|
+
message: 'Should documents match…',
|
|
743
|
+
choices: [
|
|
744
|
+
{ label: 'all of these conditions', value: 'all', hint: '— AND' },
|
|
745
|
+
{ label: 'any of these conditions', value: 'any', hint: '— OR' },
|
|
746
|
+
],
|
|
747
|
+
})
|
|
748
|
+
: 'all';
|
|
749
|
+
const filter = (0, filter_builder_js_1.combineConditions)(conditions, mode);
|
|
750
|
+
const count = await this.collection().countDocuments(filter);
|
|
751
|
+
(0, ui_js_1.heading)('Count');
|
|
752
|
+
(0, ui_js_1.info)(` ${ui_js_1.style.grey('filter')} ${JSON.stringify(filter)}`);
|
|
753
|
+
(0, ui_js_1.info)(` ${ui_js_1.style.bold(count.toLocaleString())} document(s) match`);
|
|
754
|
+
await (0, ui_js_1.pause)();
|
|
755
|
+
}
|
|
756
|
+
async distinctFlow() {
|
|
757
|
+
const field = await this.pickField('Show the distinct values of which field?');
|
|
758
|
+
if (!field)
|
|
759
|
+
return;
|
|
760
|
+
const values = await this.collection().distinct(field);
|
|
761
|
+
(0, ui_js_1.heading)(`Values of ${field}`);
|
|
762
|
+
if (values.length === 0) {
|
|
763
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' No values found.'));
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
const shown = values.slice(0, 200);
|
|
767
|
+
shown.forEach((value) => (0, ui_js_1.info)(` ${(0, format_js_1.formatCell)(value)}`));
|
|
768
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(` ${values.length.toLocaleString()} distinct value(s)${values.length > shown.length ? `, showing the first ${shown.length}` : ''}`));
|
|
769
|
+
}
|
|
770
|
+
await (0, ui_js_1.pause)();
|
|
771
|
+
}
|
|
772
|
+
async groupFlow() {
|
|
773
|
+
const field = await this.pickField('Count documents by which field?');
|
|
774
|
+
if (!field)
|
|
775
|
+
return;
|
|
776
|
+
const results = (await this.collection()
|
|
777
|
+
.aggregate([{ $group: { _id: `$${field}`, count: { $sum: 1 } } }, { $sort: { count: -1 } }])
|
|
778
|
+
.toArray());
|
|
779
|
+
(0, ui_js_1.heading)(`Documents per ${field}`);
|
|
780
|
+
if (results.length === 0) {
|
|
781
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' Nothing to count.'));
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
// `$group` names the grouping key `_id`; label it with the real field so
|
|
785
|
+
// the table reads the way the question was asked.
|
|
786
|
+
const rows = results.map((row) => ({ [field]: row._id, count: row.count }));
|
|
787
|
+
(0, ui_js_1.info)((0, format_js_1.renderTable)(rows, {
|
|
788
|
+
columns: [field, 'count'],
|
|
789
|
+
showRowNumbers: false,
|
|
790
|
+
}));
|
|
791
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(` ${results.length.toLocaleString()} group(s)`));
|
|
792
|
+
}
|
|
793
|
+
await (0, ui_js_1.pause)();
|
|
794
|
+
}
|
|
795
|
+
async pickField(message_) {
|
|
796
|
+
const schema = this.schema;
|
|
797
|
+
if (!schema || schema.fields.length === 0) {
|
|
798
|
+
(0, ui_js_1.warn)('No fields were found in this collection.');
|
|
799
|
+
await (0, ui_js_1.pause)();
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
const picked = await (0, ui_js_1.select)({
|
|
804
|
+
message: message_,
|
|
805
|
+
choices: [
|
|
806
|
+
...schema.fields.map((field) => ({
|
|
807
|
+
label: field.path,
|
|
808
|
+
value: field.path,
|
|
809
|
+
hint: (0, schema_js_1.describeField)(field),
|
|
810
|
+
})),
|
|
811
|
+
{ label: 'Type a field name myself…', value: '__custom__' },
|
|
812
|
+
],
|
|
813
|
+
pageSize: 14,
|
|
814
|
+
});
|
|
815
|
+
if (picked !== '__custom__')
|
|
816
|
+
return picked;
|
|
817
|
+
return await (0, ui_js_1.text)({ message: 'Field name' });
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
if (err instanceof ui_js_1.PromptCancelledError)
|
|
821
|
+
return null;
|
|
822
|
+
throw err;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
async showSchema() {
|
|
826
|
+
const schema = this.schema;
|
|
827
|
+
(0, ui_js_1.heading)(`Fields in ${this.collectionName}`);
|
|
828
|
+
if (!schema) {
|
|
829
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' Nothing sampled yet.'));
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
(0, ui_js_1.info)((0, format_js_1.renderSchema)(schema));
|
|
833
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(`\n Inferred from ${schema.sampled.toLocaleString()} sampled document(s) — fields that only appear in rarer documents may be missing.`));
|
|
834
|
+
}
|
|
835
|
+
await (0, ui_js_1.pause)();
|
|
836
|
+
}
|
|
837
|
+
async showIndexes() {
|
|
838
|
+
(0, ui_js_1.heading)(`Indexes on ${this.collectionName}`);
|
|
839
|
+
try {
|
|
840
|
+
const indexes = await this.collection().listIndexes().toArray();
|
|
841
|
+
if (indexes.length === 0) {
|
|
842
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' No indexes beyond the _id primary key.'));
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
for (const index of indexes) {
|
|
846
|
+
(0, ui_js_1.info)(` ${ui_js_1.style.bold(index.name)} ${ui_js_1.style.grey(JSON.stringify(index.key))}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
catch (err) {
|
|
851
|
+
(0, ui_js_1.error)(message(err));
|
|
852
|
+
}
|
|
853
|
+
await (0, ui_js_1.pause)();
|
|
854
|
+
}
|
|
855
|
+
// ------------------------------------------------------------------ escape
|
|
856
|
+
// hatches for people who do know MongoDB / SQL
|
|
857
|
+
async jsonFilterFlow() {
|
|
858
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' Enter a MongoDB-style filter as JSON, e.g. {"age": {"$gt": 25}, "isActive": true}'));
|
|
859
|
+
const raw = await (0, ui_js_1.text)({
|
|
860
|
+
message: 'Filter',
|
|
861
|
+
defaultValue: '{}',
|
|
862
|
+
validate: (input) => {
|
|
863
|
+
try {
|
|
864
|
+
const parsed = JSON.parse(input);
|
|
865
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
866
|
+
return 'A filter must be a JSON object.';
|
|
867
|
+
}
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
catch (err) {
|
|
871
|
+
return `That is not valid JSON: ${message(err)}`;
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
const filter = JSON.parse(raw);
|
|
876
|
+
const sort = await this.chooseSort();
|
|
877
|
+
await this.runQuery({ filter, sort, description: `filter ${raw}` });
|
|
878
|
+
}
|
|
879
|
+
async runRawSql() {
|
|
880
|
+
const client = this.requireClient();
|
|
881
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(` Documents live in a "data" JSON column, e.g. SELECT _id, json_extract(data, '$.name') FROM "${this.collectionName ?? 'collection'}"`));
|
|
882
|
+
const sql = await (0, ui_js_1.multilineText)('SQL to run');
|
|
883
|
+
if (sql.trim() === '')
|
|
884
|
+
return;
|
|
885
|
+
try {
|
|
886
|
+
const rows = await client.database.all(sql);
|
|
887
|
+
(0, ui_js_1.heading)('Rows');
|
|
888
|
+
if (rows.length === 0) {
|
|
889
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' No rows returned.'));
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
893
|
+
(0, ui_js_1.info)((0, format_js_1.renderTable)(rows, { columns }));
|
|
894
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(` ${rows.length.toLocaleString()} row(s)`));
|
|
895
|
+
}
|
|
896
|
+
this.lastSql = { sql, params: [] };
|
|
897
|
+
}
|
|
898
|
+
catch (err) {
|
|
899
|
+
(0, ui_js_1.error)(`SQL error: ${message(err)}`);
|
|
900
|
+
}
|
|
901
|
+
await (0, ui_js_1.pause)();
|
|
902
|
+
}
|
|
903
|
+
showLastSql() {
|
|
904
|
+
(0, ui_js_1.heading)('Last query');
|
|
905
|
+
if (!this.lastSql) {
|
|
906
|
+
(0, ui_js_1.info)(ui_js_1.style.grey(' Nothing has run yet.'));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (this.lastQuery) {
|
|
910
|
+
(0, ui_js_1.info)(`${ui_js_1.style.bold('Filter')}\n${JSON.stringify(this.lastQuery.filter, null, 2)}`);
|
|
911
|
+
}
|
|
912
|
+
(0, ui_js_1.info)((0, format_js_1.renderSql)(this.lastSql.sql, this.lastSql.params));
|
|
913
|
+
}
|
|
914
|
+
// ------------------------------------------------------------------ helpers
|
|
915
|
+
requireClient() {
|
|
916
|
+
if (!this.client)
|
|
917
|
+
throw new Error('No database is open.');
|
|
918
|
+
return this.client;
|
|
919
|
+
}
|
|
920
|
+
collection() {
|
|
921
|
+
if (!this.collectionName)
|
|
922
|
+
throw new Error('No collection is selected.');
|
|
923
|
+
return this.requireClient().collection(this.collectionName);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
exports.Explorer = Explorer;
|
|
927
|
+
function message(err) {
|
|
928
|
+
return err instanceof Error ? err.message : String(err);
|
|
929
|
+
}
|