Skip to content

feat: fetch DB tables/columns and generate resource import command #217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions adminforth/commands/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import createApp from "./createApp/main.js";
import generateModels from "./generateModels.js";
import createPlugin from "./createPlugin/main.js";
import createComponent from "./createCustomComponent/main.js";
import createResource from "./createResource/main.js";
import chalk from "chalk";
import path from "path";
import fs from "fs";
Expand Down Expand Up @@ -58,6 +59,9 @@ switch (command) {
case "component":
createComponent(args);
break;
case "resource":
createResource(args);
break;
case "help":
case "--help":
case "-h":
Expand Down
47 changes: 47 additions & 0 deletions adminforth/commands/createResource/generateResourceFile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import chalk from "chalk";
import Handlebars from "handlebars";
import { fileURLToPath } from 'url';

export async function renderHBSTemplate(templatePath, data){
const templateContent = await fs.readFile(templatePath, "utf-8");
const compiled = Handlebars.compile(templateContent);
return compiled(data);
}

export async function generateResourceFile({
table,
columns,
dataSource = "maindb",
resourcesDir = "resources"
}) {
const fileName = `${table}.ts`;
const filePath = path.resolve(process.cwd(), resourcesDir, fileName);

if (fsSync.existsSync(filePath)) {
console.log(chalk.yellow(`⚠️ File already exists: ${filePath}`));
return { alreadyExists: true, path: filePath };
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const templatePath = path.resolve(__dirname, "templates/resource.ts.hbs");
console.log(chalk.dim(`Using template: ${templatePath}`));
const context = {
table,
dataSource,
resourceId: table,
label: table.charAt(0).toUpperCase() + table.slice(1),
columns,
};

const content = await renderHBSTemplate(templatePath, context);

await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf-8");

console.log(chalk.green(`✅ Generated resource file: ${filePath}`));

return { alreadyExists: false, path: filePath };
}
113 changes: 113 additions & 0 deletions adminforth/commands/createResource/injectResourceIntoIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import fs from "fs/promises";
import path from "path";
import { parse } from "@babel/parser";
import * as recast from "recast";
import { namedTypes as n, builders as b } from "ast-types";

const parser = {
parse(source) {
return parse(source, {
sourceType: "module",
plugins: ["typescript"],
});
},
};

export async function injectResourceIntoIndex({
indexFilePath = path.resolve(process.cwd(), "index.ts"),
table,
resourceId,
label,
icon = "flowbite:user-solid",
}) {
let code = await fs.readFile(indexFilePath, "utf-8");
const ast = recast.parse(code, { parser });

const importLine = `import ${resourceId}Resource from "./resources/${table}";`;
let alreadyImported = false;

recast.visit(ast, {
visitImportDeclaration(path) {
const { node } = path;
if (
n.ImportDeclaration.check(node) &&
node.source.value === `./resources/${table}`
) {
alreadyImported = true;
return false;
}
this.traverse(path);
},
});

if (alreadyImported) {
console.warn(`⚠️ Resource already imported: ${table}`);
return;
}

// Add import at top
ast.program.body.unshift(
b.importDeclaration(
[b.importDefaultSpecifier(b.identifier(`${resourceId}Resource`))],
b.stringLiteral(`./resources/${table}`)
)
);

// Find config object with `resources` and `menu`
recast.visit(ast, {
visitObjectExpression(path) {
const node = path.node;

const resourcesProp = node.properties.find(
(p) =>
n.ObjectProperty.check(p) &&
n.Identifier.check(p.key) &&
p.key.name === "resources" &&
n.ArrayExpression.check(p.value)
);

if (resourcesProp) {
const arr = resourcesProp.value.elements;
const alreadyExists = arr.some(
(el) =>
n.Identifier.check(el) &&
el.name === `${resourceId}Resource`
);
if (!alreadyExists) {
arr.push(b.identifier(`${resourceId}Resource`));
}
}

const menuProp = node.properties.find(
(p) =>
n.ObjectProperty.check(p) &&
n.Identifier.check(p.key) &&
p.key.name === "menu" &&
n.ArrayExpression.check(p.value)
);

if (menuProp) {
const newItem = b.objectExpression([
b.objectProperty(b.identifier("label"), b.stringLiteral(capitalizeWords(label))),
b.objectProperty(b.identifier("icon"), b.stringLiteral(icon)),
b.objectProperty(b.identifier("resourceId"), b.stringLiteral(resourceId)),
]);

menuProp.value.elements.push(newItem);
}

return false; // Done
},
});

const newCode = recast.print(ast).code;
await fs.writeFile(indexFilePath, newCode, "utf-8");
console.log(`✅ Injected resource "${resourceId}" into index`);
}

function capitalizeWords(str) {
return str
.split(" ")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
55 changes: 55 additions & 0 deletions adminforth/commands/createResource/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { stringify } from 'flatted';
import { callTsProxy, findAdminInstance } from "../callTsProxy.js";
import { toTitleCase } from '../utils.js';
import { generateResourceFile } from "./generateResourceFile.js";
import { injectResourceIntoIndex } from "./injectResourceIntoIndex.js";
import { select } from "@inquirer/prompts";

export default async function createResource(args) {
console.log("Bundling admin SPA...");
const instance = await findAdminInstance();
console.log("🪲 AdminForth config:", stringify(instance.file));
console.log("🪲 Found admin instance:", instance.file);
console.log("🪲 Found admin instance:", instance.file);
console.log(JSON.stringify(instance));
const tables = await callTsProxy(`
import { admin } from './${instance.file}.js';
export async function exec() {
await admin.discoverDatabases();
return await admin.getAllTables();
}
`);

const tableChoices = Object.entries(tables).flatMap(([db, tbls]) =>
tbls.map((t) => ({
name: `${db}.${t}`,
value: { db, table: t },
}))
);

const table = await select({
message: "🗂 Choose a table to generate a resource for:",
choices: tableChoices,
});

const columns = await callTsProxy(`
import { admin } from './${instance.file}.js';
export async function exec() {
await admin.discoverDatabases();
return await admin.getAllColumnsInTable("${table.table}");
}
`);
console.log("🪲 Found columns:", columns);

generateResourceFile({
table: table.table,
columns: columns[table.db],
dataSource: table.db,
});
injectResourceIntoIndex({
table: table.table,
resourceId: table.table,
label: toTitleCase(table.table),
icon: "flowbite:user-solid",
});
}
19 changes: 19 additions & 0 deletions adminforth/commands/createResource/templates/resource.ts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { v1 as uuid } from "uuid";
import { AdminForthResourceInput } from "adminforth";

export default {
dataSource: "{{dataSource}}",
table: "{{table}}",
resourceId: "{{resourceId}}",
label: "{{label}}",
columns: [
{{#each columns}}
{
name: "{{this}}"
}{{#unless @last}},{{/unless}}
{{/each}}
],
options: {
listPageSize: 10,
},
} as AdminForthResourceInput;
26 changes: 26 additions & 0 deletions adminforth/commands/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ export const toPascalCase = (str) => {
.join("");
};

export const toCapitalizedSentence = (str) => {
const words = str
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/[_\-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
.split(' ');

if (words.length === 0) return '';

return [words[0][0].toUpperCase() + words[0].slice(1), ...words.slice(1)].join(' ');
};

export const toTitleCase = (str) => {
return str
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/[_\-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};

export const mapToTypeScriptType = (adminType) => {
switch (adminType) {
case "string":
Expand Down
12 changes: 12 additions & 0 deletions adminforth/dataConnectors/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
async setupClient(url: string): Promise<void> {
this.client = betterSqlite3(url.replace('sqlite://', ''));
}
async getAllTables(): Promise<Array<string>> {
const stmt = this.client.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';`
);
const rows = stmt.all();
return rows.map((row) => row.name);
}
async getAllColumnsInTable(tableName: string): Promise<Array<string>> {
const stmt = this.client.prepare(`PRAGMA table_info(${tableName});`);
const rows = stmt.all();
return rows.map((row) => row.name);
}

async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> {
const tableName = resource.table;
Expand Down
57 changes: 57 additions & 0 deletions adminforth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,63 @@ class AdminForth implements IAdminForth {
// console.log('⚙️⚙️⚙️ Database discovery done', JSON.stringify(this.config.resources, null, 2));
}

async getAllTables(): Promise<{ [dataSourceId: string]: string[] }> {
const results: { [dataSourceId: string]: string[] } = {};

// console.log('Connectors to process:', Object.keys(this.connectors));
if (!this.config.databaseConnectors) {
this.config.databaseConnectors = {...this.connectorClasses};
}

await Promise.all(
Object.entries(this.connectors).map(async ([dataSourceId, connector]) => {
if (typeof connector.getAllTables === 'function') {
try {
const tables = await connector.getAllTables();
results[dataSourceId] = tables;
} catch (err) {
console.error(`Error getting tables for dataSource ${dataSourceId}:`, err);
results[dataSourceId] = [];
}
} else {
// console.log(`Connector ${dataSourceId} does not have getAllTables method`);
results[dataSourceId] = [];
}
})
);

return results;
}

async getAllColumnsInTable(
tableName: string
): Promise<{ [dataSourceId: string]: string[] }> {
const results: { [dataSourceId: string]: string[] } = {};

if (!this.config.databaseConnectors) {
this.config.databaseConnectors = { ...this.connectorClasses };
}

await Promise.all(
Object.entries(this.connectors).map(async ([dataSourceId, connector]) => {
if (typeof connector.getAllColumnsInTable === 'function') {
try {
const columns = await connector.getAllColumnsInTable(tableName);
results[dataSourceId] = columns;
} catch (err) {
console.error(`Error getting columns for table ${tableName} in dataSource ${dataSourceId}:`, err);
results[dataSourceId] = [];
}
} else {
results[dataSourceId] = [];
}
})
);

return results;
}


async bundleNow({ hotReload=false }) {
await this.codeInjector.bundleNow({ hotReload });
}
Expand Down
9 changes: 9 additions & 0 deletions adminforth/types/Back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ export interface IAdminForthDataSourceConnector {
*/
setupClient(url: string): Promise<void>;

/**
* Function to get all tables from database.
*/
getAllTables(): Promise<Array<string>>;

/**
* Function to get all columns in table.
*/
getAllColumnsInTable(tableName: string): Promise<Array<string>>;
/**
* Optional.
* You an redefine this function to define how one record should be fetched from database.
Expand Down