Skip to content

Commit db95c72

Browse files
committed
feat: fetch DB tables/columns and generate resource import command
1 parent 4de0654 commit db95c72

File tree

9 files changed

+342
-0
lines changed

9 files changed

+342
-0
lines changed

adminforth/commands/cli.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import createApp from "./createApp/main.js";
88
import generateModels from "./generateModels.js";
99
import createPlugin from "./createPlugin/main.js";
1010
import createComponent from "./createCustomComponent/main.js";
11+
import createResource from "./createResource/main.js";
1112
import chalk from "chalk";
1213
import path from "path";
1314
import fs from "fs";
@@ -58,6 +59,9 @@ switch (command) {
5859
case "component":
5960
createComponent(args);
6061
break;
62+
case "resource":
63+
createResource(args);
64+
break;
6165
case "help":
6266
case "--help":
6367
case "-h":
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import fs from "fs/promises";
2+
import fsSync from "fs";
3+
import path from "path";
4+
import chalk from "chalk";
5+
import Handlebars from "handlebars";
6+
import { fileURLToPath } from 'url';
7+
8+
export async function renderHBSTemplate(templatePath, data){
9+
const templateContent = await fs.readFile(templatePath, "utf-8");
10+
const compiled = Handlebars.compile(templateContent);
11+
return compiled(data);
12+
}
13+
14+
export async function generateResourceFile({
15+
table,
16+
columns,
17+
dataSource = "maindb",
18+
resourcesDir = "resources"
19+
}) {
20+
const fileName = `${table}.ts`;
21+
const filePath = path.resolve(process.cwd(), resourcesDir, fileName);
22+
23+
if (fsSync.existsSync(filePath)) {
24+
console.log(chalk.yellow(`⚠️ File already exists: ${filePath}`));
25+
return { alreadyExists: true, path: filePath };
26+
}
27+
const __filename = fileURLToPath(import.meta.url);
28+
const __dirname = path.dirname(__filename);
29+
const templatePath = path.resolve(__dirname, "templates/resource.ts.hbs");
30+
console.log(chalk.dim(`Using template: ${templatePath}`));
31+
const context = {
32+
table,
33+
dataSource,
34+
resourceId: table,
35+
label: table.charAt(0).toUpperCase() + table.slice(1),
36+
columns,
37+
};
38+
39+
const content = await renderHBSTemplate(templatePath, context);
40+
41+
await fs.mkdir(path.dirname(filePath), { recursive: true });
42+
await fs.writeFile(filePath, content, "utf-8");
43+
44+
console.log(chalk.green(`✅ Generated resource file: ${filePath}`));
45+
46+
return { alreadyExists: false, path: filePath };
47+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import fs from "fs/promises";
2+
import path from "path";
3+
import { parse } from "@babel/parser";
4+
import * as recast from "recast";
5+
import { namedTypes as n, builders as b } from "ast-types";
6+
7+
const parser = {
8+
parse(source) {
9+
return parse(source, {
10+
sourceType: "module",
11+
plugins: ["typescript"],
12+
});
13+
},
14+
};
15+
16+
export async function injectResourceIntoIndex({
17+
indexFilePath = path.resolve(process.cwd(), "index.ts"),
18+
table,
19+
resourceId,
20+
label,
21+
icon = "flowbite:user-solid",
22+
}) {
23+
let code = await fs.readFile(indexFilePath, "utf-8");
24+
const ast = recast.parse(code, { parser });
25+
26+
const importLine = `import ${resourceId}Resource from "./resources/${table}";`;
27+
let alreadyImported = false;
28+
29+
recast.visit(ast, {
30+
visitImportDeclaration(path) {
31+
const { node } = path;
32+
if (
33+
n.ImportDeclaration.check(node) &&
34+
node.source.value === `./resources/${table}`
35+
) {
36+
alreadyImported = true;
37+
return false;
38+
}
39+
this.traverse(path);
40+
},
41+
});
42+
43+
if (alreadyImported) {
44+
console.warn(`⚠️ Resource already imported: ${table}`);
45+
return;
46+
}
47+
48+
// Add import at top
49+
ast.program.body.unshift(
50+
b.importDeclaration(
51+
[b.importDefaultSpecifier(b.identifier(`${resourceId}Resource`))],
52+
b.stringLiteral(`./resources/${table}`)
53+
)
54+
);
55+
56+
// Find config object with `resources` and `menu`
57+
recast.visit(ast, {
58+
visitObjectExpression(path) {
59+
const node = path.node;
60+
61+
const resourcesProp = node.properties.find(
62+
(p) =>
63+
n.ObjectProperty.check(p) &&
64+
n.Identifier.check(p.key) &&
65+
p.key.name === "resources" &&
66+
n.ArrayExpression.check(p.value)
67+
);
68+
69+
if (resourcesProp) {
70+
const arr = resourcesProp.value.elements;
71+
const alreadyExists = arr.some(
72+
(el) =>
73+
n.Identifier.check(el) &&
74+
el.name === `${resourceId}Resource`
75+
);
76+
if (!alreadyExists) {
77+
arr.push(b.identifier(`${resourceId}Resource`));
78+
}
79+
}
80+
81+
const menuProp = node.properties.find(
82+
(p) =>
83+
n.ObjectProperty.check(p) &&
84+
n.Identifier.check(p.key) &&
85+
p.key.name === "menu" &&
86+
n.ArrayExpression.check(p.value)
87+
);
88+
89+
if (menuProp) {
90+
const newItem = b.objectExpression([
91+
b.objectProperty(b.identifier("label"), b.stringLiteral(capitalizeWords(label))),
92+
b.objectProperty(b.identifier("icon"), b.stringLiteral(icon)),
93+
b.objectProperty(b.identifier("resourceId"), b.stringLiteral(resourceId)),
94+
]);
95+
96+
menuProp.value.elements.push(newItem);
97+
}
98+
99+
return false; // Done
100+
},
101+
});
102+
103+
const newCode = recast.print(ast).code;
104+
await fs.writeFile(indexFilePath, newCode, "utf-8");
105+
console.log(`✅ Injected resource "${resourceId}" into index`);
106+
}
107+
108+
function capitalizeWords(str) {
109+
return str
110+
.split(" ")
111+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
112+
.join(" ");
113+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { stringify } from 'flatted';
2+
import { callTsProxy, findAdminInstance } from "../callTsProxy.js";
3+
import { toTitleCase } from '../utils.js';
4+
import { generateResourceFile } from "./generateResourceFile.js";
5+
import { injectResourceIntoIndex } from "./injectResourceIntoIndex.js";
6+
import { select } from "@inquirer/prompts";
7+
8+
export default async function createResource(args) {
9+
console.log("Bundling admin SPA...");
10+
const instance = await findAdminInstance();
11+
console.log("🪲 AdminForth config:", stringify(instance.file));
12+
console.log("🪲 Found admin instance:", instance.file);
13+
console.log("🪲 Found admin instance:", instance.file);
14+
console.log(JSON.stringify(instance));
15+
const tables = await callTsProxy(`
16+
import { admin } from './${instance.file}.js';
17+
export async function exec() {
18+
await admin.discoverDatabases();
19+
return await admin.getAllTables();
20+
}
21+
`);
22+
23+
const tableChoices = Object.entries(tables).flatMap(([db, tbls]) =>
24+
tbls.map((t) => ({
25+
name: `${db}.${t}`,
26+
value: { db, table: t },
27+
}))
28+
);
29+
30+
const table = await select({
31+
message: "🗂 Choose a table to generate a resource for:",
32+
choices: tableChoices,
33+
});
34+
35+
const columns = await callTsProxy(`
36+
import { admin } from './${instance.file}.js';
37+
export async function exec() {
38+
await admin.discoverDatabases();
39+
return await admin.getAllColumnsInTable("${table.table}");
40+
}
41+
`);
42+
console.log("🪲 Found columns:", columns);
43+
44+
generateResourceFile({
45+
table: table.table,
46+
columns: columns[table.db],
47+
dataSource: table.db,
48+
});
49+
injectResourceIntoIndex({
50+
table: table.table,
51+
resourceId: table.table,
52+
label: toTitleCase(table.table),
53+
icon: "flowbite:user-solid",
54+
});
55+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { v1 as uuid } from "uuid";
2+
import { AdminForthResourceInput } from "adminforth";
3+
4+
export default {
5+
dataSource: "{{dataSource}}",
6+
table: "{{table}}",
7+
resourceId: "{{resourceId}}",
8+
label: "{{label}}",
9+
columns: [
10+
{{#each columns}}
11+
{
12+
name: "{{this}}"
13+
}{{#unless @last}},{{/unless}}
14+
{{/each}}
15+
],
16+
options: {
17+
listPageSize: 10,
18+
},
19+
} as AdminForthResourceInput;

adminforth/commands/utils.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,32 @@ export const toPascalCase = (str) => {
99
.join("");
1010
};
1111

12+
export const toCapitalizedSentence = (str) => {
13+
const words = str
14+
.replace(/([a-z])([A-Z])/g, '$1 $2')
15+
.replace(/[_\-]+/g, ' ')
16+
.replace(/\s+/g, ' ')
17+
.trim()
18+
.toLowerCase()
19+
.split(' ');
20+
21+
if (words.length === 0) return '';
22+
23+
return [words[0][0].toUpperCase() + words[0].slice(1), ...words.slice(1)].join(' ');
24+
};
25+
26+
export const toTitleCase = (str) => {
27+
return str
28+
.replace(/([a-z])([A-Z])/g, '$1 $2')
29+
.replace(/[_\-]+/g, ' ')
30+
.replace(/\s+/g, ' ')
31+
.trim()
32+
.toLowerCase()
33+
.split(' ')
34+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
35+
.join(' ');
36+
};
37+
1238
export const mapToTypeScriptType = (adminType) => {
1339
switch (adminType) {
1440
case "string":

adminforth/dataConnectors/sqlite.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
99
async setupClient(url: string): Promise<void> {
1010
this.client = betterSqlite3(url.replace('sqlite://', ''));
1111
}
12+
async getAllTables(): Promise<Array<string>> {
13+
const stmt = this.client.prepare(
14+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';`
15+
);
16+
const rows = stmt.all();
17+
return rows.map((row) => row.name);
18+
}
19+
async getAllColumnsInTable(tableName: string): Promise<Array<string>> {
20+
const stmt = this.client.prepare(`PRAGMA table_info(${tableName});`);
21+
const rows = stmt.all();
22+
return rows.map((row) => row.name);
23+
}
1224

1325
async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> {
1426
const tableName = resource.table;

adminforth/index.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,63 @@ class AdminForth implements IAdminForth {
369369
// console.log('⚙️⚙️⚙️ Database discovery done', JSON.stringify(this.config.resources, null, 2));
370370
}
371371

372+
async getAllTables(): Promise<{ [dataSourceId: string]: string[] }> {
373+
const results: { [dataSourceId: string]: string[] } = {};
374+
375+
// console.log('Connectors to process:', Object.keys(this.connectors));
376+
if (!this.config.databaseConnectors) {
377+
this.config.databaseConnectors = {...this.connectorClasses};
378+
}
379+
380+
await Promise.all(
381+
Object.entries(this.connectors).map(async ([dataSourceId, connector]) => {
382+
if (typeof connector.getAllTables === 'function') {
383+
try {
384+
const tables = await connector.getAllTables();
385+
results[dataSourceId] = tables;
386+
} catch (err) {
387+
console.error(`Error getting tables for dataSource ${dataSourceId}:`, err);
388+
results[dataSourceId] = [];
389+
}
390+
} else {
391+
// console.log(`Connector ${dataSourceId} does not have getAllTables method`);
392+
results[dataSourceId] = [];
393+
}
394+
})
395+
);
396+
397+
return results;
398+
}
399+
400+
async getAllColumnsInTable(
401+
tableName: string
402+
): Promise<{ [dataSourceId: string]: string[] }> {
403+
const results: { [dataSourceId: string]: string[] } = {};
404+
405+
if (!this.config.databaseConnectors) {
406+
this.config.databaseConnectors = { ...this.connectorClasses };
407+
}
408+
409+
await Promise.all(
410+
Object.entries(this.connectors).map(async ([dataSourceId, connector]) => {
411+
if (typeof connector.getAllColumnsInTable === 'function') {
412+
try {
413+
const columns = await connector.getAllColumnsInTable(tableName);
414+
results[dataSourceId] = columns;
415+
} catch (err) {
416+
console.error(`Error getting columns for table ${tableName} in dataSource ${dataSourceId}:`, err);
417+
results[dataSourceId] = [];
418+
}
419+
} else {
420+
results[dataSourceId] = [];
421+
}
422+
})
423+
);
424+
425+
return results;
426+
}
427+
428+
372429
async bundleNow({ hotReload=false }) {
373430
await this.codeInjector.bundleNow({ hotReload });
374431
}

adminforth/types/Back.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ export interface IAdminForthDataSourceConnector {
135135
*/
136136
setupClient(url: string): Promise<void>;
137137

138+
/**
139+
* Function to get all tables from database.
140+
*/
141+
getAllTables(): Promise<Array<string>>;
142+
143+
/**
144+
* Function to get all columns in table.
145+
*/
146+
getAllColumnsInTable(tableName: string): Promise<Array<string>>;
138147
/**
139148
* Optional.
140149
* You an redefine this function to define how one record should be fetched from database.

0 commit comments

Comments
 (0)