Skip to content

Commit c707dd6

Browse files
authored
Merge pull request #607 from devforth/resource-columns-sync
feat: enhance resource file generation and column synchronization logic
2 parents 3b5399a + c0c7db4 commit c707dd6

5 files changed

Lines changed: 256 additions & 18 deletions

File tree

adminforth/commands/callTsProxy.js

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@ import dotenv from "dotenv";
88
const currentFilePath = import.meta.url;
99
const currentFileFolder = path.dirname(currentFilePath).replace("file:", "");
1010

11+
function getLocalBinPath(currentDirectory) {
12+
return path.join(currentDirectory, "node_modules", ".bin");
13+
}
14+
15+
function getLocalBinExecutable(currentDirectory, command) {
16+
const extension = process.platform === "win32" ? ".cmd" : "";
17+
const executablePath = path.join(getLocalBinPath(currentDirectory), `${command}${extension}`);
18+
return fs.existsSync(executablePath) ? executablePath : command;
19+
}
20+
21+
function getEnvWithLocalBin(currentDirectory) {
22+
const pathKey = process.platform === "win32" ? "Path" : "PATH";
23+
const localBinPath = getLocalBinPath(currentDirectory);
24+
const currentPath = process.env[pathKey] || "";
25+
26+
return {
27+
...process.env,
28+
[pathKey]: [localBinPath, currentPath].filter(Boolean).join(path.delimiter),
29+
};
30+
}
31+
1132
export function callTsProxy(tsCode, silent=false) {
1233

1334
const currentDirectory = process.cwd();
@@ -22,28 +43,37 @@ export function callTsProxy(tsCode, silent=false) {
2243

2344
process.env.HEAVY_DEBUG && console.log("🌐 Calling tsproxy with code:", path.join(currentFileFolder, "proxy.ts"));
2445
return new Promise((resolve, reject) => {
25-
const child = spawn("tsx", [path.join(currentFileFolder, "proxy.ts")], {
26-
env: process.env,
46+
const child = spawn(getLocalBinExecutable(currentDirectory, "tsx"), [path.join(currentFileFolder, "proxy.ts")], {
47+
env: getEnvWithLocalBin(currentDirectory),
2748
});
2849
let stderr = "";
29-
let stdoutLogs = [];
50+
let stdout = "";
3051

3152
child.stdout.on("data", (data) => {
32-
stdoutLogs.push(data.toString());
53+
stdout += data.toString();
3354
});
3455

3556
child.stderr.on("data", (data) => {
3657
stderr += data;
3758
});
3859

60+
child.on("error", (error) => {
61+
reject(error);
62+
});
63+
3964
child.on("close", (code) => {
40-
const tsProxyResult = stdoutLogs.find(log => log.includes('>>>>>>>'));
41-
const preparedStdout = tsProxyResult.slice(tsProxyResult.indexOf('>>>>>>>') + 7, tsProxyResult.lastIndexOf('<<<<<<<'));
42-
const preparedStdoutLogs = stdoutLogs.filter(log => !log.includes('>>>>>>>'));
65+
const resultStart = stdout.indexOf('>>>>>>>');
66+
const resultEnd = stdout.lastIndexOf('<<<<<<<');
67+
if (resultStart === -1 || resultEnd === -1 || resultEnd < resultStart) {
68+
reject(new Error(`Invalid JSON from tsproxy. stdout: ${stdout}, stderr: ${stderr}`));
69+
return;
70+
}
71+
const preparedStdout = stdout.slice(resultStart + 7, resultEnd);
72+
const preparedStdoutLogs = stdout.slice(0, resultStart);
4373
if (code === 0) {
4474
try {
45-
for (const log of preparedStdoutLogs) {
46-
console.log(log);
75+
if (preparedStdoutLogs) {
76+
process.stdout.write(preparedStdoutLogs);
4777
}
4878
const parsed = JSON.parse(preparedStdout);
4979
if (!silent) {

adminforth/commands/createResource/generateResourceFile.js

Lines changed: 205 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@ import path from "path";
44
import chalk from "chalk";
55
import Handlebars from "handlebars";
66
import { fileURLToPath } from 'url';
7+
import { parse } from "@babel/parser";
8+
import * as recast from "recast";
9+
import { namedTypes as n, builders as b } from "ast-types";
10+
11+
const DATA_SOURCE_RE = /dataSource:\s*["'](.+?)["']/;
12+
const ADMINFORTH_DATA_TYPE_KEYS = {
13+
string: "STRING",
14+
integer: "INTEGER",
15+
float: "FLOAT",
16+
decimal: "DECIMAL",
17+
boolean: "BOOLEAN",
18+
date: "DATE",
19+
datetime: "DATETIME",
20+
time: "TIME",
21+
text: "TEXT",
22+
json: "JSON",
23+
};
24+
25+
const parser = {
26+
parse(source) {
27+
return parse(source, {
28+
sourceType: "module",
29+
plugins: ["typescript"],
30+
});
31+
},
32+
};
733

834
export async function renderHBSTemplate(templatePath, data){
935
const templateContent = await fs.readFile(templatePath, "utf-8");
@@ -22,14 +48,35 @@ export async function generateResourceFile({
2248

2349
if (fsSync.existsSync(baseFilePath)) {
2450
const content = await fs.readFile(baseFilePath, "utf-8");
25-
const match = content.match(/dataSource:\s*["'](.+?)["']/);
51+
const match = content.match(DATA_SOURCE_RE);
2652
const existingDataSource = match?.[1];
2753
if (existingDataSource === dataSource) {
28-
console.log(chalk.yellow(`⚠️ File already exists with same dataSource: ${baseFilePath}`));
29-
return { alreadyExists: true, path: baseFilePath, fileName: baseFileName, resourceId: table };
54+
const syncedColumnsCount = await syncResourceColumns(baseFilePath, content, columns);
55+
return {
56+
alreadyExists: true,
57+
path: baseFilePath,
58+
fileName: baseFileName,
59+
resourceId: table,
60+
syncedColumnsCount,
61+
};
3062
} else {
3163
const suffixedFileName = `${table}_${dataSource}.ts`;
3264
const suffixedFilePath = path.resolve(process.cwd(), resourcesDir, suffixedFileName);
65+
if (fsSync.existsSync(suffixedFilePath)) {
66+
const suffixedContent = await fs.readFile(suffixedFilePath, "utf-8");
67+
const suffixedMatch = suffixedContent.match(DATA_SOURCE_RE);
68+
const suffixedDataSource = suffixedMatch?.[1];
69+
if (suffixedDataSource === dataSource) {
70+
const syncedColumnsCount = await syncResourceColumns(suffixedFilePath, suffixedContent, columns);
71+
return {
72+
alreadyExists: true,
73+
path: suffixedFilePath,
74+
fileName: suffixedFileName,
75+
resourceId: `${table}_${dataSource}`,
76+
syncedColumnsCount,
77+
};
78+
}
79+
}
3380
return await writeResourceFile(suffixedFilePath, suffixedFileName, {
3481
table,
3582
columns,
@@ -63,7 +110,7 @@ async function writeResourceFile(filePath, fileName, {
63110
dataSource,
64111
resourceId,
65112
label: table.charAt(0).toUpperCase() + table.slice(1),
66-
columns,
113+
columns: columns.map(normalizeColumnForTemplate),
67114
};
68115

69116
const content = await renderHBSTemplate(templatePath, context);
@@ -74,3 +121,157 @@ async function writeResourceFile(filePath, fileName, {
74121

75122
return { alreadyExists: false, path: filePath, fileName, resourceId };
76123
}
124+
125+
async function syncResourceColumns(filePath, content, discoveredColumns) {
126+
const ast = recast.parse(content, { parser });
127+
const columnsArray = findResourceColumnsArray(ast);
128+
129+
if (!columnsArray) {
130+
throw new Error(`Could not find resource columns array in ${filePath}`);
131+
}
132+
133+
const dynamicColumnElements = columnsArray.elements.filter((element) => !n.ObjectExpression.check(element));
134+
if (dynamicColumnElements.length) {
135+
throw new Error(
136+
`Resource columns array in ${filePath} contains dynamic entries. ` +
137+
`Please sync this resource manually because automatic column import only supports literal column objects.`
138+
);
139+
}
140+
141+
const existingColumnNames = new Set(
142+
columnsArray.elements
143+
.map((element) => getObjectPropertyValue(element, "name"))
144+
.filter(Boolean)
145+
);
146+
147+
const columnsToImport = discoveredColumns.filter((column) => !existingColumnNames.has(column.name));
148+
149+
if (!columnsToImport.length) {
150+
console.log(chalk.green(`✅ Resource is already in sync: ${filePath}`));
151+
return 0;
152+
}
153+
154+
console.log(chalk.cyan(`ℹ️ Going to import ${formatColumnsCount(columnsToImport.length)}: ${columnsToImport.map((column) => column.name).join(", ")}`));
155+
156+
columnsArray.elements.push(...columnsToImport.map(createColumnAstNode));
157+
158+
const newContent = recast.print(ast, {
159+
tabWidth: 2,
160+
useTabs: false,
161+
trailingComma: true,
162+
wrapColumn: 1,
163+
}).code;
164+
165+
await fs.writeFile(filePath, newContent, "utf-8");
166+
console.log(chalk.green(`✅ Imported ${formatColumnsCount(columnsToImport.length)} into resource file: ${filePath}`));
167+
168+
return columnsToImport.length;
169+
}
170+
171+
function findResourceColumnsArray(ast) {
172+
let columnsArray = null;
173+
174+
recast.visit(ast, {
175+
visitObjectExpression(path) {
176+
const properties = path.node.properties;
177+
const columnsProp = findObjectProperty(properties, "columns");
178+
const hasResourceShape = findObjectProperty(properties, "dataSource") && findObjectProperty(properties, "table");
179+
180+
if (hasResourceShape && columnsProp && n.ArrayExpression.check(columnsProp.value)) {
181+
columnsArray = columnsProp.value;
182+
return false;
183+
}
184+
185+
this.traverse(path);
186+
},
187+
});
188+
189+
return columnsArray;
190+
}
191+
192+
function findObjectProperty(properties, name) {
193+
return properties.find((property) => (
194+
n.ObjectProperty.check(property) &&
195+
getPropertyKeyName(property) === name
196+
));
197+
}
198+
199+
function getPropertyKeyName(property) {
200+
if (n.Identifier.check(property.key)) {
201+
return property.key.name;
202+
}
203+
if (n.StringLiteral.check(property.key) || n.Literal.check(property.key)) {
204+
return property.key.value;
205+
}
206+
return null;
207+
}
208+
209+
function getObjectPropertyValue(objectExpression, name) {
210+
const property = findObjectProperty(objectExpression.properties, name);
211+
if (!property) {
212+
return null;
213+
}
214+
if (n.StringLiteral.check(property.value) || n.Literal.check(property.value)) {
215+
return property.value.value;
216+
}
217+
return null;
218+
}
219+
220+
function createColumnAstNode(column) {
221+
const properties = [
222+
b.objectProperty(b.identifier("name"), b.stringLiteral(column.name)),
223+
];
224+
225+
if (column.type) {
226+
properties.push(
227+
b.objectProperty(
228+
b.identifier("type"),
229+
b.memberExpression(b.identifier("AdminForthDataTypes"), b.identifier(getAdminForthDataTypeKey(column.type)))
230+
)
231+
);
232+
}
233+
234+
if (column.isPrimaryKey) {
235+
properties.push(b.objectProperty(b.identifier("primaryKey"), b.booleanLiteral(true)));
236+
}
237+
238+
if (column.isUUID) {
239+
properties.push(
240+
b.objectProperty(
241+
b.identifier("components"),
242+
b.objectExpression([
243+
b.objectProperty(b.identifier("list"), b.stringLiteral("@/renderers/CompactUUID.vue")),
244+
])
245+
)
246+
);
247+
}
248+
249+
properties.push(
250+
b.objectProperty(
251+
b.identifier("showIn"),
252+
b.objectExpression([
253+
b.objectProperty(b.identifier("all"), b.booleanLiteral(true)),
254+
])
255+
)
256+
);
257+
258+
return b.objectExpression(properties);
259+
}
260+
261+
function formatColumnsCount(count) {
262+
return `${count} column${count === 1 ? "" : "s"}`;
263+
}
264+
265+
function normalizeColumnForTemplate(column) {
266+
if (!column.type) {
267+
return column;
268+
}
269+
return {
270+
...column,
271+
type: getAdminForthDataTypeKey(column.type),
272+
};
273+
}
274+
275+
function getAdminForthDataTypeKey(type) {
276+
return ADMINFORTH_DATA_TYPE_KEYS[type] || type;
277+
}

adminforth/commands/createResource/main.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export default async function createResource(args) {
4343
import { admin } from './${instance.file}.js';
4444
export async function exec() {
4545
await admin.discoverDatabases();
46-
const columns = await admin.getAllColumnsInTable("${table.table}");
46+
const columns = await admin.getAllColumnsInTable("${table.table}", "${table.db}");
4747
setTimeout(process.exit);
4848
return columns;
4949
}
@@ -55,7 +55,7 @@ export default async function createResource(args) {
5555
dataSource: table.db,
5656
});
5757

58-
injectResourceIntoIndex({
58+
await injectResourceIntoIndex({
5959
table: resourceId,
6060
resourceId: resourceId,
6161
label: toTitleCase(table.table),

adminforth/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,8 @@ class AdminForth implements IAdminForth {
521521
}
522522

523523
async getAllColumnsInTable(
524-
tableName: string
524+
tableName: string,
525+
requestedDataSourceId?: string
525526
): Promise<{ [dataSourceId: string]: Array<{ name: string; type?: string; isPrimaryKey?: boolean; isUUID?: boolean; }> }> {
526527
const results: { [dataSourceId: string]: Array<{ name: string; type?: string; isPrimaryKey?: boolean; isUUID?: boolean; }> } = {};
527528

@@ -530,7 +531,9 @@ class AdminForth implements IAdminForth {
530531
}
531532

532533
await Promise.all(
533-
Object.entries(this.connectors).map(async ([dataSourceId, connector]) => {
534+
Object.entries(this.connectors)
535+
.filter(([dataSourceId]) => !requestedDataSourceId || dataSourceId === requestedDataSourceId)
536+
.map(async ([dataSourceId, connector]) => {
534537
if (typeof connector.getAllColumnsInTable === 'function') {
535538
try {
536539
const columns = await connector.getAllColumnsInTable(tableName);
@@ -835,4 +838,4 @@ class AdminForth implements IAdminForth {
835838

836839
}
837840

838-
export default AdminForth;
841+
export default AdminForth;

adminforth/modules/codeInjector.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ class CodeInjector implements ICodeInjector {
147147

148148

149149
async doesUserHasPnpmLockFile(dir: string): Promise<boolean> {
150+
if (!dir) {
151+
return false;
152+
}
153+
150154
const usersPackagePath = path.join(dir, 'package.json');
151155
let packageContent: { dependencies: any, devDependencies: any } = null;
152156
try {

0 commit comments

Comments
 (0)