modscape 2.0.2 → 2.0.3
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/package.json +1 -1
- package/src/column.js +100 -0
- package/src/domain.js +149 -0
- package/src/extract.js +72 -0
- package/src/index.js +24 -2
- package/src/lineage.js +69 -0
- package/src/model-utils.js +53 -0
- package/src/relationship.js +93 -0
- package/src/table.js +118 -0
- package/src/templates/claude/modeling.md +13 -0
- package/src/templates/codex/modscape-modeling/SKILL.md +13 -0
- package/src/templates/gemini/modscape-modeling/SKILL.md +13 -0
- package/src/templates/rules.md +100 -2
- package/visualizer/package.json +1 -1
- package/visualizer-dist/assets/{index-BFdr345z.js → index-nIGQ5zIu.js} +1 -1
- package/visualizer-dist/index.html +1 -1
package/package.json
CHANGED
package/src/column.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readYaml, writeYaml, findTableById, outputError, outputWarn, outputOk } from './model-utils.js';
|
|
3
|
+
|
|
4
|
+
function findColumnById(table, id) {
|
|
5
|
+
return (table.columns || []).find(c => c.id === id) || null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function columnCommand() {
|
|
9
|
+
const cmd = new Command('column').description('Manage columns in a table');
|
|
10
|
+
|
|
11
|
+
// add
|
|
12
|
+
cmd
|
|
13
|
+
.command('add <file>')
|
|
14
|
+
.description('Add a column to a table')
|
|
15
|
+
.requiredOption('--table <tableId>', 'target table ID')
|
|
16
|
+
.requiredOption('--id <id>', 'column ID (snake_case)')
|
|
17
|
+
.requiredOption('--name <name>', 'logical name')
|
|
18
|
+
.option('--type <type>', 'logical type (Int|String|Decimal|Date|Timestamp|Boolean|...)')
|
|
19
|
+
.option('--primary-key', 'mark as primary key')
|
|
20
|
+
.option('--foreign-key', 'mark as foreign key')
|
|
21
|
+
.option('--physical-name <name>', 'physical column name')
|
|
22
|
+
.option('--physical-type <type>', 'physical column type (e.g. BIGINT)')
|
|
23
|
+
.option('--json', 'output as JSON')
|
|
24
|
+
.action((file, opts) => {
|
|
25
|
+
const data = readYaml(file);
|
|
26
|
+
const table = findTableById(data, opts.table);
|
|
27
|
+
if (!table) return outputError(opts.json, `Table "${opts.table}" not found`);
|
|
28
|
+
if (findColumnById(table, opts.id)) {
|
|
29
|
+
return outputError(opts.json, `Column "${opts.id}" already exists in table "${opts.table}"`, 'Use `column update` instead');
|
|
30
|
+
}
|
|
31
|
+
const column = { id: opts.id, logical: { name: opts.name } };
|
|
32
|
+
if (opts.type) column.logical.type = opts.type;
|
|
33
|
+
if (opts.primaryKey) column.logical.isPrimaryKey = true;
|
|
34
|
+
if (opts.foreignKey) column.logical.isForeignKey = true;
|
|
35
|
+
if (opts.physicalName || opts.physicalType) {
|
|
36
|
+
column.physical = {};
|
|
37
|
+
if (opts.physicalName) column.physical.name = opts.physicalName;
|
|
38
|
+
if (opts.physicalType) column.physical.type = opts.physicalType;
|
|
39
|
+
}
|
|
40
|
+
if (!table.columns) table.columns = [];
|
|
41
|
+
table.columns.push(column);
|
|
42
|
+
writeYaml(file, data);
|
|
43
|
+
outputOk(opts.json, 'add', 'column', `${opts.table}.${opts.id}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// update
|
|
47
|
+
cmd
|
|
48
|
+
.command('update <file>')
|
|
49
|
+
.description('Update a column in a table')
|
|
50
|
+
.requiredOption('--table <tableId>', 'target table ID')
|
|
51
|
+
.requiredOption('--id <id>', 'column ID to update')
|
|
52
|
+
.option('--name <name>', 'logical name')
|
|
53
|
+
.option('--type <type>', 'logical type')
|
|
54
|
+
.option('--primary-key <bool>', 'set isPrimaryKey (true|false)')
|
|
55
|
+
.option('--foreign-key <bool>', 'set isForeignKey (true|false)')
|
|
56
|
+
.option('--physical-name <name>', 'physical column name')
|
|
57
|
+
.option('--physical-type <type>', 'physical column type')
|
|
58
|
+
.option('--json', 'output as JSON')
|
|
59
|
+
.action((file, opts) => {
|
|
60
|
+
const data = readYaml(file);
|
|
61
|
+
const table = findTableById(data, opts.table);
|
|
62
|
+
if (!table) return outputError(opts.json, `Table "${opts.table}" not found`);
|
|
63
|
+
const column = findColumnById(table, opts.id);
|
|
64
|
+
if (!column) return outputError(opts.json, `Column "${opts.id}" not found in table "${opts.table}"`, 'Use `column add` instead');
|
|
65
|
+
column.logical = column.logical || {};
|
|
66
|
+
if (opts.name) column.logical.name = opts.name;
|
|
67
|
+
if (opts.type) column.logical.type = opts.type;
|
|
68
|
+
if (opts.primaryKey !== undefined) column.logical.isPrimaryKey = opts.primaryKey === 'true';
|
|
69
|
+
if (opts.foreignKey !== undefined) column.logical.isForeignKey = opts.foreignKey === 'true';
|
|
70
|
+
if (opts.physicalName || opts.physicalType) {
|
|
71
|
+
column.physical = column.physical || {};
|
|
72
|
+
if (opts.physicalName) column.physical.name = opts.physicalName;
|
|
73
|
+
if (opts.physicalType) column.physical.type = opts.physicalType;
|
|
74
|
+
}
|
|
75
|
+
writeYaml(file, data);
|
|
76
|
+
outputOk(opts.json, 'update', 'column', `${opts.table}.${opts.id}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// remove
|
|
80
|
+
cmd
|
|
81
|
+
.command('remove <file>')
|
|
82
|
+
.description('Remove a column from a table')
|
|
83
|
+
.requiredOption('--table <tableId>', 'target table ID')
|
|
84
|
+
.requiredOption('--id <id>', 'column ID to remove')
|
|
85
|
+
.option('--json', 'output as JSON')
|
|
86
|
+
.action((file, opts) => {
|
|
87
|
+
const data = readYaml(file);
|
|
88
|
+
const table = findTableById(data, opts.table);
|
|
89
|
+
if (!table) return outputError(opts.json, `Table "${opts.table}" not found`);
|
|
90
|
+
const before = (table.columns || []).length;
|
|
91
|
+
table.columns = (table.columns || []).filter(c => c.id !== opts.id);
|
|
92
|
+
if (table.columns.length === before) {
|
|
93
|
+
return outputWarn(opts.json, `Column "${opts.id}" not found in table "${opts.table}", nothing removed`);
|
|
94
|
+
}
|
|
95
|
+
writeYaml(file, data);
|
|
96
|
+
outputOk(opts.json, 'remove', 'column', `${opts.table}.${opts.id}`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return cmd;
|
|
100
|
+
}
|
package/src/domain.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readYaml, writeYaml, findTableById, findDomainById, outputError, outputWarn, outputOk } from './model-utils.js';
|
|
3
|
+
|
|
4
|
+
export function domainCommand() {
|
|
5
|
+
const cmd = new Command('domain').description('Manage domains in a YAML model');
|
|
6
|
+
|
|
7
|
+
// list
|
|
8
|
+
cmd
|
|
9
|
+
.command('list <file>')
|
|
10
|
+
.description('List all domains')
|
|
11
|
+
.option('--json', 'output as JSON')
|
|
12
|
+
.action((file, opts) => {
|
|
13
|
+
const data = readYaml(file);
|
|
14
|
+
const domains = (data.domains || []).map(d => ({ id: d.id, name: d.name }));
|
|
15
|
+
if (opts.json) {
|
|
16
|
+
console.log(JSON.stringify(domains));
|
|
17
|
+
} else {
|
|
18
|
+
if (domains.length === 0) {
|
|
19
|
+
console.log(' (no domains)');
|
|
20
|
+
} else {
|
|
21
|
+
domains.forEach(d => console.log(` ${d.id} ${d.name || ''}`));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// get
|
|
27
|
+
cmd
|
|
28
|
+
.command('get <file>')
|
|
29
|
+
.description('Get a domain definition by ID')
|
|
30
|
+
.requiredOption('--id <id>', 'domain ID')
|
|
31
|
+
.option('--json', 'output as JSON')
|
|
32
|
+
.action((file, opts) => {
|
|
33
|
+
const data = readYaml(file);
|
|
34
|
+
const domain = findDomainById(data, opts.id);
|
|
35
|
+
if (!domain) return outputError(opts.json, `Domain "${opts.id}" not found`);
|
|
36
|
+
if (opts.json) {
|
|
37
|
+
console.log(JSON.stringify(domain));
|
|
38
|
+
} else {
|
|
39
|
+
console.log(JSON.stringify(domain, null, 2));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// add
|
|
44
|
+
cmd
|
|
45
|
+
.command('add <file>')
|
|
46
|
+
.description('Add a new domain')
|
|
47
|
+
.requiredOption('--id <id>', 'domain ID (snake_case)')
|
|
48
|
+
.requiredOption('--name <name>', 'display name')
|
|
49
|
+
.option('--description <text>', 'description')
|
|
50
|
+
.option('--color <color>', 'background color (e.g. rgba(59,130,246,0.1))')
|
|
51
|
+
.option('--json', 'output as JSON')
|
|
52
|
+
.action((file, opts) => {
|
|
53
|
+
const data = readYaml(file);
|
|
54
|
+
if (findDomainById(data, opts.id)) {
|
|
55
|
+
return outputError(opts.json, `Domain "${opts.id}" already exists`, 'Use `domain update` instead');
|
|
56
|
+
}
|
|
57
|
+
const domain = { id: opts.id, name: opts.name, tables: [] };
|
|
58
|
+
if (opts.description) domain.description = opts.description;
|
|
59
|
+
if (opts.color) domain.color = opts.color;
|
|
60
|
+
if (!data.domains) data.domains = [];
|
|
61
|
+
data.domains.push(domain);
|
|
62
|
+
writeYaml(file, data);
|
|
63
|
+
outputOk(opts.json, 'add', 'domain', opts.id);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// update
|
|
67
|
+
cmd
|
|
68
|
+
.command('update <file>')
|
|
69
|
+
.description('Update fields of an existing domain')
|
|
70
|
+
.requiredOption('--id <id>', 'domain ID to update')
|
|
71
|
+
.option('--name <name>', 'display name')
|
|
72
|
+
.option('--description <text>', 'description')
|
|
73
|
+
.option('--color <color>', 'background color')
|
|
74
|
+
.option('--json', 'output as JSON')
|
|
75
|
+
.action((file, opts) => {
|
|
76
|
+
const data = readYaml(file);
|
|
77
|
+
const domain = findDomainById(data, opts.id);
|
|
78
|
+
if (!domain) return outputError(opts.json, `Domain "${opts.id}" not found`, 'Use `domain add` instead');
|
|
79
|
+
if (opts.name) domain.name = opts.name;
|
|
80
|
+
if (opts.description) domain.description = opts.description;
|
|
81
|
+
if (opts.color) domain.color = opts.color;
|
|
82
|
+
writeYaml(file, data);
|
|
83
|
+
outputOk(opts.json, 'update', 'domain', opts.id);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// remove
|
|
87
|
+
cmd
|
|
88
|
+
.command('remove <file>')
|
|
89
|
+
.description('Remove a domain from the model')
|
|
90
|
+
.requiredOption('--id <id>', 'domain ID to remove')
|
|
91
|
+
.option('--json', 'output as JSON')
|
|
92
|
+
.action((file, opts) => {
|
|
93
|
+
const data = readYaml(file);
|
|
94
|
+
const before = (data.domains || []).length;
|
|
95
|
+
data.domains = (data.domains || []).filter(d => d.id !== opts.id);
|
|
96
|
+
if (data.domains.length === before) {
|
|
97
|
+
return outputWarn(opts.json, `Domain "${opts.id}" not found, nothing removed`);
|
|
98
|
+
}
|
|
99
|
+
writeYaml(file, data);
|
|
100
|
+
outputOk(opts.json, 'remove', 'domain', opts.id);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// member subcommand
|
|
104
|
+
const member = new Command('member').description('Manage table membership in a domain');
|
|
105
|
+
|
|
106
|
+
member
|
|
107
|
+
.command('add <file>')
|
|
108
|
+
.description('Add a table to a domain')
|
|
109
|
+
.requiredOption('--domain <domainId>', 'domain ID')
|
|
110
|
+
.requiredOption('--table <tableId>', 'table ID to add')
|
|
111
|
+
.option('--json', 'output as JSON')
|
|
112
|
+
.action((file, opts) => {
|
|
113
|
+
const data = readYaml(file);
|
|
114
|
+
const domain = findDomainById(data, opts.domain);
|
|
115
|
+
if (!domain) return outputError(opts.json, `Domain "${opts.domain}" not found`);
|
|
116
|
+
if (!findTableById(data, opts.table)) {
|
|
117
|
+
return outputError(opts.json, `Table "${opts.table}" not found`);
|
|
118
|
+
}
|
|
119
|
+
if (!domain.tables) domain.tables = [];
|
|
120
|
+
if (domain.tables.includes(opts.table)) {
|
|
121
|
+
return outputWarn(opts.json, `Table "${opts.table}" is already in domain "${opts.domain}", skipped`);
|
|
122
|
+
}
|
|
123
|
+
domain.tables.push(opts.table);
|
|
124
|
+
writeYaml(file, data);
|
|
125
|
+
outputOk(opts.json, 'member_add', 'domain', `${opts.domain} ← ${opts.table}`);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
member
|
|
129
|
+
.command('remove <file>')
|
|
130
|
+
.description('Remove a table from a domain')
|
|
131
|
+
.requiredOption('--domain <domainId>', 'domain ID')
|
|
132
|
+
.requiredOption('--table <tableId>', 'table ID to remove')
|
|
133
|
+
.option('--json', 'output as JSON')
|
|
134
|
+
.action((file, opts) => {
|
|
135
|
+
const data = readYaml(file);
|
|
136
|
+
const domain = findDomainById(data, opts.domain);
|
|
137
|
+
if (!domain) return outputError(opts.json, `Domain "${opts.domain}" not found`);
|
|
138
|
+
const before = (domain.tables || []).length;
|
|
139
|
+
domain.tables = (domain.tables || []).filter(t => t !== opts.table);
|
|
140
|
+
if (domain.tables.length === before) {
|
|
141
|
+
return outputWarn(opts.json, `Table "${opts.table}" not found in domain "${opts.domain}", nothing removed`);
|
|
142
|
+
}
|
|
143
|
+
writeYaml(file, data);
|
|
144
|
+
outputOk(opts.json, 'member_remove', 'domain', `${opts.domain} ✕ ${opts.table}`);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
cmd.addCommand(member);
|
|
148
|
+
return cmd;
|
|
149
|
+
}
|
package/src/extract.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
const collectYamlFiles = (inputPath) => {
|
|
6
|
+
const stat = fs.statSync(inputPath);
|
|
7
|
+
if (stat.isDirectory()) {
|
|
8
|
+
return fs.readdirSync(inputPath)
|
|
9
|
+
.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
|
|
10
|
+
.map(f => path.join(inputPath, f));
|
|
11
|
+
}
|
|
12
|
+
return [inputPath];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function extractModels(inputs, options) {
|
|
16
|
+
const outputPath = options.output || 'extracted.yaml';
|
|
17
|
+
const tableIds = options.tables
|
|
18
|
+
? options.tables.split(',').map(id => id.trim()).filter(Boolean)
|
|
19
|
+
: [];
|
|
20
|
+
|
|
21
|
+
if (tableIds.length === 0) {
|
|
22
|
+
console.error(' ❌ --tables option is required. Specify comma-separated table IDs.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 後勝ちマージ用 Map
|
|
27
|
+
const tableMap = new Map();
|
|
28
|
+
|
|
29
|
+
const allFiles = [];
|
|
30
|
+
for (const input of inputs) {
|
|
31
|
+
allFiles.push(...collectYamlFiles(input));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (allFiles.length === 0) {
|
|
35
|
+
console.error(' ❌ No YAML files found');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const filePath of allFiles) {
|
|
40
|
+
try {
|
|
41
|
+
const data = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
42
|
+
if (!data) continue;
|
|
43
|
+
|
|
44
|
+
let matched = 0;
|
|
45
|
+
for (const table of data.tables || []) {
|
|
46
|
+
if (tableIds.includes(table.id)) {
|
|
47
|
+
tableMap.set(table.id, table); // 後勝ち上書き
|
|
48
|
+
matched++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(` 📄 ${filePath} (${matched} matched)`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error(` ❌ Failed to read ${filePath}: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// マッチしなかった ID を警告
|
|
59
|
+
for (const id of tableIds) {
|
|
60
|
+
if (!tableMap.has(id)) {
|
|
61
|
+
console.warn(` ⚠️ Table ID not found: "${id}"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const outputModel = {
|
|
66
|
+
tables: [...tableMap.values()],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
fs.writeFileSync(outputPath, yaml.dump(outputModel), 'utf8');
|
|
70
|
+
console.log(`\n ✅ Extracted ${tableMap.size} tables → ${outputPath}`);
|
|
71
|
+
console.log(` 🚀 Run 'modscape dev ${outputPath}' to visualize.`);
|
|
72
|
+
}
|
package/src/index.js
CHANGED
|
@@ -12,8 +12,14 @@ import { importDbt } from './import-dbt.js';
|
|
|
12
12
|
import { syncDbt } from './sync-dbt.js';
|
|
13
13
|
import { applyLayout } from './layout.js';
|
|
14
14
|
import { createRequire } from 'module';
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
import { mergeModels } from './merge.js';
|
|
16
|
+
import { extractModels } from './extract.js';
|
|
17
|
+
import { tableCommand } from './table.js';
|
|
18
|
+
import { columnCommand } from './column.js';
|
|
19
|
+
import { relationshipCommand } from './relationship.js';
|
|
20
|
+
import { lineageCommand } from './lineage.js';
|
|
21
|
+
import { domainCommand } from './domain.js';
|
|
22
|
+
|
|
17
23
|
const require = createRequire(import.meta.url);
|
|
18
24
|
const pkg = require('../package.json');
|
|
19
25
|
|
|
@@ -105,6 +111,22 @@ program
|
|
|
105
111
|
mergeModels(paths, options);
|
|
106
112
|
});
|
|
107
113
|
|
|
114
|
+
program
|
|
115
|
+
.command('extract')
|
|
116
|
+
.description('Extract specific tables from YAML models by ID')
|
|
117
|
+
.argument('<paths...>', 'YAML files or directories to extract from')
|
|
118
|
+
.option('-t, --tables <ids>', 'comma-separated table IDs to extract')
|
|
119
|
+
.option('-o, --output <path>', 'output file path', 'extracted.yaml')
|
|
120
|
+
.action((paths, options) => {
|
|
121
|
+
extractModels(paths, options);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
program.addCommand(tableCommand());
|
|
125
|
+
program.addCommand(columnCommand());
|
|
126
|
+
program.addCommand(relationshipCommand());
|
|
127
|
+
program.addCommand(lineageCommand());
|
|
128
|
+
program.addCommand(domainCommand());
|
|
129
|
+
|
|
108
130
|
program
|
|
109
131
|
.command('layout')
|
|
110
132
|
.description('Perform automatic layout calculation and update the YAML file')
|
package/src/lineage.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readYaml, writeYaml, findTableById, outputError, outputWarn, outputOk } from './model-utils.js';
|
|
3
|
+
|
|
4
|
+
export function lineageCommand() {
|
|
5
|
+
const cmd = new Command('lineage').description('Manage data lineage in a YAML model');
|
|
6
|
+
|
|
7
|
+
// list
|
|
8
|
+
cmd
|
|
9
|
+
.command('list <file>')
|
|
10
|
+
.description('List all lineage entries')
|
|
11
|
+
.option('--json', 'output as JSON')
|
|
12
|
+
.action((file, opts) => {
|
|
13
|
+
const data = readYaml(file);
|
|
14
|
+
const entries = data.lineage || [];
|
|
15
|
+
if (opts.json) {
|
|
16
|
+
console.log(JSON.stringify(entries));
|
|
17
|
+
} else {
|
|
18
|
+
if (entries.length === 0) {
|
|
19
|
+
console.log(' (no lineage)');
|
|
20
|
+
} else {
|
|
21
|
+
entries.forEach(e => console.log(` ${e.from} --> ${e.to}`));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// add
|
|
27
|
+
cmd
|
|
28
|
+
.command('add <file>')
|
|
29
|
+
.description('Add a lineage entry (data flow from → to)')
|
|
30
|
+
.requiredOption('--from <tableId>', 'upstream table ID')
|
|
31
|
+
.requiredOption('--to <tableId>', 'downstream table ID')
|
|
32
|
+
.option('--json', 'output as JSON')
|
|
33
|
+
.action((file, opts) => {
|
|
34
|
+
const data = readYaml(file);
|
|
35
|
+
if (!findTableById(data, opts.from)) {
|
|
36
|
+
return outputError(opts.json, `Table "${opts.from}" not found`);
|
|
37
|
+
}
|
|
38
|
+
if (!findTableById(data, opts.to)) {
|
|
39
|
+
return outputError(opts.json, `Table "${opts.to}" not found`);
|
|
40
|
+
}
|
|
41
|
+
const entries = data.lineage || [];
|
|
42
|
+
if (entries.some(e => e.from === opts.from && e.to === opts.to)) {
|
|
43
|
+
return outputWarn(opts.json, `Lineage ${opts.from} → ${opts.to} already exists, skipped`);
|
|
44
|
+
}
|
|
45
|
+
data.lineage = [...entries, { from: opts.from, to: opts.to }];
|
|
46
|
+
writeYaml(file, data);
|
|
47
|
+
outputOk(opts.json, 'add', 'lineage', `${opts.from} → ${opts.to}`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// remove
|
|
51
|
+
cmd
|
|
52
|
+
.command('remove <file>')
|
|
53
|
+
.description('Remove a lineage entry')
|
|
54
|
+
.requiredOption('--from <tableId>', 'upstream table ID')
|
|
55
|
+
.requiredOption('--to <tableId>', 'downstream table ID')
|
|
56
|
+
.option('--json', 'output as JSON')
|
|
57
|
+
.action((file, opts) => {
|
|
58
|
+
const data = readYaml(file);
|
|
59
|
+
const before = (data.lineage || []).length;
|
|
60
|
+
data.lineage = (data.lineage || []).filter(e => !(e.from === opts.from && e.to === opts.to));
|
|
61
|
+
if (data.lineage.length === before) {
|
|
62
|
+
return outputWarn(opts.json, `Lineage ${opts.from} → ${opts.to} not found, nothing removed`);
|
|
63
|
+
}
|
|
64
|
+
writeYaml(file, data);
|
|
65
|
+
outputOk(opts.json, 'remove', 'lineage', `${opts.from} → ${opts.to}`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return cmd;
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
|
|
4
|
+
export function readYaml(filePath) {
|
|
5
|
+
const data = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
6
|
+
return data || {};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function writeYaml(filePath, data) {
|
|
10
|
+
fs.writeFileSync(filePath, yaml.dump(data, { lineWidth: -1 }), 'utf8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findTableById(data, id) {
|
|
14
|
+
return (data.tables || []).find(t => t.id === id) || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function findDomainById(data, id) {
|
|
18
|
+
return (data.domains || []).find(d => d.id === id) || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function output(json, message, opts = {}) {
|
|
22
|
+
if (opts.json) {
|
|
23
|
+
console.log(JSON.stringify(message));
|
|
24
|
+
} else {
|
|
25
|
+
console.log(message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function outputError(json, message, hint) {
|
|
30
|
+
if (json) {
|
|
31
|
+
console.error(JSON.stringify({ ok: false, error: message, hint: hint || '' }));
|
|
32
|
+
} else {
|
|
33
|
+
console.error(` ❌ ${message}${hint ? '\n 💡 ' + hint : ''}`);
|
|
34
|
+
}
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function outputWarn(json, message) {
|
|
39
|
+
if (json) {
|
|
40
|
+
console.error(JSON.stringify({ ok: false, warning: message }));
|
|
41
|
+
} else {
|
|
42
|
+
console.warn(` ⚠️ ${message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function outputOk(json, action, resource, id, extra = {}) {
|
|
47
|
+
if (json) {
|
|
48
|
+
console.log(JSON.stringify({ ok: true, action, resource, id, ...extra }));
|
|
49
|
+
} else {
|
|
50
|
+
const icons = { add: '✅', update: '✏️', remove: '🗑️', list: '📋', get: '🔍', member_add: '✅', member_remove: '🗑️' };
|
|
51
|
+
console.log(` ${icons[action] || '✅'} ${action} ${resource}: ${id}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readYaml, writeYaml, findTableById, outputError, outputWarn, outputOk } from './model-utils.js';
|
|
3
|
+
|
|
4
|
+
// Parse "table.column" or "table" into { tableId, columnId }
|
|
5
|
+
function parseRef(ref) {
|
|
6
|
+
const parts = ref.split('.');
|
|
7
|
+
return { tableId: parts[0], columnId: parts[1] || null };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function sameRel(r, fromTableId, toTableId) {
|
|
11
|
+
return r.from?.table === fromTableId && r.to?.table === toTableId;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function relationshipCommand() {
|
|
15
|
+
const cmd = new Command('relationship').description('Manage relationships in a YAML model');
|
|
16
|
+
|
|
17
|
+
// list
|
|
18
|
+
cmd
|
|
19
|
+
.command('list <file>')
|
|
20
|
+
.description('List all relationships')
|
|
21
|
+
.option('--json', 'output as JSON')
|
|
22
|
+
.action((file, opts) => {
|
|
23
|
+
const data = readYaml(file);
|
|
24
|
+
const rels = data.relationships || [];
|
|
25
|
+
if (opts.json) {
|
|
26
|
+
console.log(JSON.stringify(rels));
|
|
27
|
+
} else {
|
|
28
|
+
if (rels.length === 0) {
|
|
29
|
+
console.log(' (no relationships)');
|
|
30
|
+
} else {
|
|
31
|
+
rels.forEach(r => {
|
|
32
|
+
const from = r.from?.column ? `${r.from.table}.${r.from.column}` : r.from?.table;
|
|
33
|
+
const to = r.to?.column ? `${r.to.table}.${r.to.column}` : r.to?.table;
|
|
34
|
+
console.log(` ${from} --[${r.type}]--> ${to}`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// add
|
|
41
|
+
cmd
|
|
42
|
+
.command('add <file>')
|
|
43
|
+
.description('Add a relationship between two tables')
|
|
44
|
+
.requiredOption('--from <ref>', 'source table (table.column or table)')
|
|
45
|
+
.requiredOption('--to <ref>', 'target table (table.column or table)')
|
|
46
|
+
.requiredOption('--type <type>', 'cardinality (one-to-one|one-to-many|many-to-one|many-to-many)')
|
|
47
|
+
.option('--json', 'output as JSON')
|
|
48
|
+
.action((file, opts) => {
|
|
49
|
+
const data = readYaml(file);
|
|
50
|
+
const from = parseRef(opts.from);
|
|
51
|
+
const to = parseRef(opts.to);
|
|
52
|
+
if (!findTableById(data, from.tableId)) {
|
|
53
|
+
return outputError(opts.json, `Table "${from.tableId}" not found`);
|
|
54
|
+
}
|
|
55
|
+
if (!findTableById(data, to.tableId)) {
|
|
56
|
+
return outputError(opts.json, `Table "${to.tableId}" not found`);
|
|
57
|
+
}
|
|
58
|
+
const rels = data.relationships || [];
|
|
59
|
+
if (rels.some(r => sameRel(r, from.tableId, to.tableId))) {
|
|
60
|
+
return outputWarn(opts.json, `Relationship ${from.tableId} → ${to.tableId} already exists, skipped`);
|
|
61
|
+
}
|
|
62
|
+
const rel = {
|
|
63
|
+
from: { table: from.tableId, ...(from.columnId ? { column: from.columnId } : {}) },
|
|
64
|
+
to: { table: to.tableId, ...(to.columnId ? { column: to.columnId } : {}) },
|
|
65
|
+
type: opts.type,
|
|
66
|
+
};
|
|
67
|
+
data.relationships = [...rels, rel];
|
|
68
|
+
writeYaml(file, data);
|
|
69
|
+
outputOk(opts.json, 'add', 'relationship', `${opts.from} → ${opts.to}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// remove
|
|
73
|
+
cmd
|
|
74
|
+
.command('remove <file>')
|
|
75
|
+
.description('Remove a relationship')
|
|
76
|
+
.requiredOption('--from <ref>', 'source table (table.column or table)')
|
|
77
|
+
.requiredOption('--to <ref>', 'target table (table.column or table)')
|
|
78
|
+
.option('--json', 'output as JSON')
|
|
79
|
+
.action((file, opts) => {
|
|
80
|
+
const data = readYaml(file);
|
|
81
|
+
const from = parseRef(opts.from);
|
|
82
|
+
const to = parseRef(opts.to);
|
|
83
|
+
const before = (data.relationships || []).length;
|
|
84
|
+
data.relationships = (data.relationships || []).filter(r => !sameRel(r, from.tableId, to.tableId));
|
|
85
|
+
if (data.relationships.length === before) {
|
|
86
|
+
return outputWarn(opts.json, `Relationship ${from.tableId} → ${to.tableId} not found, nothing removed`);
|
|
87
|
+
}
|
|
88
|
+
writeYaml(file, data);
|
|
89
|
+
outputOk(opts.json, 'remove', 'relationship', `${opts.from} → ${opts.to}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return cmd;
|
|
93
|
+
}
|