Skip to content

Commit 809e6fd

Browse files
committed
feat: skip Prisma prompt for non-empty databases
1 parent 5a5786b commit 809e6fd

9 files changed

Lines changed: 168 additions & 9 deletions

File tree

adminforth/commands/createApp/utils.js

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ const adminforthVersion = detectAdminforthVersion();
4040
const SUPPORTED_DB_URL_SCHEMES = ['sqlite://', 'postgresql://', 'mongodb://', 'mysql://', 'clickhouse://'];
4141
const PRISMA_MIGRATION_DB_PROTOCOLS = ['sqlite', 'postgres', 'postgresql', 'mysql'];
4242
const DEFAULT_DB_URL = 'sqlite://.db.sqlite';
43+
const DATABASE_CONNECTOR_IMPORTS = {
44+
sqlite: '../../dist/dataConnectors/sqlite.js',
45+
postgresql: '../../dist/dataConnectors/postgres.js',
46+
mysql: '../../dist/dataConnectors/mysql.js',
47+
mongodb: '../../dist/dataConnectors/mongo.js',
48+
clickhouse: '../../dist/dataConnectors/clickhouse.js',
49+
};
4350

4451

4552
export function parseArgumentsIntoOptions(rawArgs) {
@@ -58,7 +65,6 @@ export function parseArgumentsIntoOptions(rawArgs) {
5865
return {
5966
appName: args['--app-name'],
6067
db: args['--db'],
61-
dbProvided: args['--db'] !== undefined,
6268
useNpm: args['--use-npm'],
6369
};
6470
}
@@ -113,7 +119,7 @@ ENGINE = MergeTree()
113119
ORDER BY id;
114120
\`\`\`
115121
116-
ClickHouse does not enforce UNIQUE constraints like PostgreSQL, MySQL, or SQLite. AdminForth authentication expects `email` values in `adminuser` to be unique, so enforce this in your ingestion/application logic and remove duplicate email rows to avoid ambiguous logins.`;
122+
ClickHouse does not enforce UNIQUE constraints like PostgreSQL, MySQL, or SQLite. AdminForth authentication expects \`email\` values in \`adminuser\` to be unique, so enforce this in your ingestion/application logic and remove duplicate email rows to avoid ambiguous logins.`;
117123
}
118124

119125
if (provider === 'mongodb') {
@@ -164,8 +170,30 @@ export async function promptForMissingOptions(options) {
164170
db: options.db || answers.db,
165171
useNpm: options.useNpm || answers.useNpm,
166172
};
167-
resolvedOptions.existingDb = options.dbProvided || resolvedOptions.db !== DEFAULT_DB_URL;
168-
resolvedOptions.includePrismaMigrations = !resolvedOptions.existingDb && isPrismaMigrationDbUrl(resolvedOptions.db);
173+
resolvedOptions.databaseCleanState = { blockingObjects: [] };
174+
resolvedOptions.existingDb = false;
175+
176+
await inspectDatabaseCleanState(resolvedOptions);
177+
178+
if (
179+
resolvedOptions.includePrismaMigrations === undefined &&
180+
isPrismaMigrationDbUrl(resolvedOptions.db) &&
181+
!resolvedOptions.existingDb
182+
) {
183+
const prismaAnswer = await inquirer.prompt([{
184+
type: 'select',
185+
name: 'includePrismaMigrations',
186+
message: 'Include Prisma migrations? >',
187+
choices: [
188+
{ name: 'Yes', value: true },
189+
{ name: 'No', value: false },
190+
],
191+
default: true,
192+
}]);
193+
resolvedOptions.includePrismaMigrations = prismaAnswer.includePrismaMigrations;
194+
} else {
195+
resolvedOptions.includePrismaMigrations = Boolean(resolvedOptions.includePrismaMigrations) && !resolvedOptions.existingDb;
196+
}
169197

170198
return resolvedOptions;
171199
}
@@ -234,6 +262,50 @@ function generateDbUrlForAfProd(connectionString) {
234262
return connectionString.toString();
235263
}
236264

265+
function getSqliteInspectionUrl(dbUrl, appName) {
266+
const connectionString = parseConnectionString(dbUrl);
267+
const sqliteFile = connectionString.host;
268+
const resolvedSqliteFile = path.isAbsolute(sqliteFile)
269+
? sqliteFile
270+
: path.join(process.cwd(), appName, sqliteFile);
271+
272+
if (!fs.existsSync(resolvedSqliteFile)) {
273+
return null;
274+
}
275+
276+
return `sqlite://${resolvedSqliteFile}`;
277+
}
278+
279+
async function inspectDatabaseCleanState(options) {
280+
const connectionString = parseConnectionString(options.db);
281+
const provider = detectDbProvider(connectionString.protocol);
282+
let inspectionDbUrl = connectionString.toString();
283+
284+
if (provider === 'sqlite') {
285+
const sqliteInspectionUrl = getSqliteInspectionUrl(options.db, options.appName);
286+
if (!sqliteInspectionUrl) {
287+
options.databaseCleanState = { blockingObjects: [] };
288+
options.existingDb = false;
289+
return;
290+
}
291+
inspectionDbUrl = sqliteInspectionUrl;
292+
}
293+
294+
const Connector = (await import(DATABASE_CONNECTOR_IMPORTS[provider])).default;
295+
const connector = new Connector();
296+
await connector.setupClient(inspectionDbUrl);
297+
298+
try {
299+
options.databaseCleanState = await connector.isDatabaseEmpty();
300+
} finally {
301+
if (typeof connector.close === 'function') {
302+
await connector.close();
303+
}
304+
}
305+
306+
options.existingDb = options.databaseCleanState.blockingObjects.length > 0;
307+
}
308+
237309
function initialChecks(options) {
238310
return [
239311
{

adminforth/dataConnectors/baseConnector.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter,
55
AdminForthConfig,
66
IAggregationRule, IGroupByRule, IGroupByDateTrunc,
7+
DatabaseCleanState,
78
} from "../types/Back.js";
89

910

@@ -696,5 +697,9 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
696697
throw new Error('getAllColumnsInTable() must be implemented in subclass');
697698
}
698699

700+
async isDatabaseEmpty(): Promise<DatabaseCleanState> {
701+
throw new Error('isDatabaseEmpty() must be implemented in subclass');
702+
}
703+
699704

700705
}

adminforth/dataConnectors/clickhouse.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js';
1+
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js';
22
import AdminForthBaseConnector from './baseConnector.js';
33
import dayjs from 'dayjs';
44
import utc from 'dayjs/plugin/utc.js';
@@ -87,6 +87,23 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
8787
sampleValue: sampleRow[col.name],
8888
}));
8989
}
90+
91+
async isDatabaseEmpty(): Promise<DatabaseCleanState> {
92+
const res = await this.client.query({
93+
query: `
94+
SELECT database, name, engine
95+
FROM system.tables
96+
WHERE database = currentDatabase()
97+
AND database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA')
98+
AND is_temporary = 0
99+
`,
100+
format: 'JSONEachRow',
101+
});
102+
const rows = await res.json();
103+
return {
104+
blockingObjects: rows.map((row: any) => row.name),
105+
};
106+
}
90107

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

adminforth/dataConnectors/mongo.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import dayjs from 'dayjs';
22
import { MongoClient } from 'mongodb';
33
import { Decimal128, Double } from 'bson';
4-
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js';
4+
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js';
55
import AdminForthBaseConnector from './baseConnector.js';
66
import { afLogger } from '../modules/logger.js';
77
import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js';
@@ -166,6 +166,15 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
166166
};
167167
});
168168
}
169+
170+
async isDatabaseEmpty(): Promise<DatabaseCleanState> {
171+
const collections = await this.client.db().listCollections({}, { nameOnly: true }).toArray();
172+
return {
173+
blockingObjects: collections
174+
.filter((collection) => !collection.name.startsWith('system.'))
175+
.map((collection) => collection.name),
176+
};
177+
}
169178

170179

171180
async discoverFields(resource) {

adminforth/dataConnectors/mysql.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import dayjs from 'dayjs';
22
import utc from 'dayjs/plugin/utc.js';
3-
import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js';
3+
import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js';
44
import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js';
55
import AdminForthBaseConnector from './baseConnector.js';
66
import mysql from 'mysql2/promise';
@@ -77,6 +77,18 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
7777
}));
7878
}
7979

80+
async isDatabaseEmpty(): Promise<DatabaseCleanState> {
81+
const [rows] = await this.client.execute(`
82+
SELECT table_schema, table_name
83+
FROM information_schema.tables
84+
WHERE table_schema = DATABASE()
85+
AND table_type = 'BASE TABLE'
86+
`);
87+
return {
88+
blockingObjects: rows.map((row: any) => row.TABLE_NAME ?? row.table_name),
89+
};
90+
}
91+
8092
async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise<boolean> {
8193

8294
const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade');

adminforth/dataConnectors/postgres.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import dayjs from 'dayjs';
2-
import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js';
2+
import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js';
33
import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js';
44
import AdminForthBaseConnector from './baseConnector.js';
55
import pkg from 'pg';
@@ -89,6 +89,19 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa
8989
const sampleRow = sampleRowRes.rows[0] ?? {};
9090
return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] }));
9191
}
92+
93+
async isDatabaseEmpty(): Promise<DatabaseCleanState> {
94+
const res = await this.client.query(`
95+
SELECT table_schema, table_name
96+
FROM information_schema.tables
97+
WHERE table_type = 'BASE TABLE'
98+
AND table_schema NOT IN ('pg_catalog', 'information_schema')
99+
AND table_schema NOT LIKE 'pg_toast%'
100+
`);
101+
return {
102+
blockingObjects: res.rows.map((row) => `${row.table_schema}.${row.table_name}`),
103+
};
104+
}
92105

93106
async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise<void> {
94107
const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade');

adminforth/dataConnectors/qdrant.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
IAdminForthDataSourceConnector,
1010
IAdminForthSingleFilter,
1111
IAdminForthSort,
12+
DatabaseCleanState,
1213
} from '../types/Back.js';
1314
import {
1415
AdminForthDataTypes,
@@ -95,6 +96,13 @@ class QdrantConnector extends AdminForthBaseConnector implements IAdminForthData
9596
return (collections.collections ?? []).map((collection: { name: string }) => collection.name);
9697
}
9798

99+
async isDatabaseEmpty(): Promise<DatabaseCleanState> {
100+
const collections = await this.client.getCollections();
101+
return {
102+
blockingObjects: (collections.collections ?? []).map((collection: { name: string }) => collection.name),
103+
};
104+
}
105+
98106
// discover fields
99107
async discoverFields(resource) {
100108
return resource.columns.filter((col) => !col.virtual).reduce((acc, col) => {

adminforth/dataConnectors/sqlite.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import betterSqlite3 from 'better-sqlite3';
2-
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js';
2+
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField, DatabaseCleanState } from '../types/Back.js';
33
import AdminForthBaseConnector from './baseConnector.js';
44
import dayjs from 'dayjs';
55
import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from '../types/Common.js';
@@ -51,6 +51,19 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
5151
sampleValue: sampleRow[col.name],
5252
}));
5353
}
54+
55+
async isDatabaseEmpty(): Promise<DatabaseCleanState> {
56+
const stmt = this.client.prepare(`
57+
SELECT name
58+
FROM sqlite_schema
59+
WHERE type = 'table'
60+
AND name NOT LIKE 'sqlite_%'
61+
`);
62+
const rows = stmt.all();
63+
return {
64+
blockingObjects: rows.map((row) => row.name),
65+
};
66+
}
5467

5568
async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise<boolean> {
5669
const cascadeColumn = resource.columns?.find(c => c.foreignResource?.onDelete === 'cascade');

adminforth/types/Back.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,10 @@ export interface IAdminForthSort {
276276
direction: AdminForthSortDirections
277277
}
278278

279+
export type DatabaseCleanState = {
280+
blockingObjects: string[];
281+
};
282+
279283
export interface IAdminForthDataSourceConnector {
280284

281285
client: any;
@@ -297,6 +301,12 @@ export interface IAdminForthDataSourceConnector {
297301
* Function to get all columns in table.
298302
*/
299303
getAllColumnsInTable(tableName: string): Promise<Array<{ name: string; type?: string; isPrimaryKey?: boolean; sampleValue?: any; }>>;
304+
305+
/**
306+
* Function to check whether database has no user data.
307+
*/
308+
isDatabaseEmpty(): Promise<DatabaseCleanState>;
309+
300310
/**
301311
* Optional.
302312
* You an redefine this function to define how one record should be fetched from database.

0 commit comments

Comments
 (0)