Skip to content

Commit ca9ef12

Browse files
committed
Merge branch 'next' of github.com:devforth/adminforth into next
2 parents 41d7f8a + 259cecc commit ca9ef12

File tree

12 files changed

+448
-150
lines changed

12 files changed

+448
-150
lines changed
Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { v1 as uuid } from "uuid";
2-
import { AdminForthResourceInput } from "adminforth";
1+
import { AdminForthResourceInput, AdminForthDataTypes } from "adminforth";
32

43
export default {
54
dataSource: "{{dataSource}}",
@@ -9,11 +8,16 @@ export default {
98
columns: [
109
{{#each columns}}
1110
{
12-
name: "{{this}}"
11+
name: "{{this.name}}"{{#if this.type}},
12+
type: AdminForthDataTypes.{{this.type}}{{/if}}{{#if this.isPrimaryKey}},
13+
primaryKey: true{{/if}}{{#if this.isUUID}},
14+
components: {
15+
list: "@/renderers/CompactUUID.vue",
16+
}{{/if}},
1317
}{{#unless @last}},{{/unless}}
1418
{{/each}}
1519
],
1620
options: {
1721
listPageSize: 10,
1822
},
19-
} as AdminForthResourceInput;
23+
} as AdminForthResourceInput;

adminforth/dataConnectors/baseConnector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
357357
throw new Error('getAllTables() must be implemented in subclass');
358358
}
359359

360-
async getAllColumnsInTable(tableName: string): Promise<string[]> {
360+
async getAllColumnsInTable(tableName: string): Promise<Array<{ name: string; type?: string; isPrimaryKey?: boolean; sampleValue?: any; }>> {
361361
throw new Error('getAllColumnsInTable() must be implemented in subclass');
362362
}
363363

adminforth/dataConnectors/clickhouse.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
4343
return jsonResult.data.map((row: any) => row.name);
4444
}
4545

46-
async getAllColumnsInTable(tableName: string): Promise<string[]> {
46+
async getAllColumnsInTable(tableName: string): Promise<Array<{ name: string; sampleValue?: any }>> {
4747
const res = await this.client.query({
4848
query: `
4949
SELECT name
@@ -57,7 +57,31 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
5757
});
5858

5959
const jsonResult = await res.json();
60-
return jsonResult.data.map((row: any) => row.name);
60+
const orderByField = ['updated_at', 'created_at', 'id'].find(f =>
61+
jsonResult.data.some((col: any) => col.name === f)
62+
);
63+
64+
let sampleRow = {};
65+
if (orderByField) {
66+
const sampleRes = await this.client.query({
67+
query: `SELECT * FROM ${this.dbName}.${tableName} ORDER BY ${orderByField} DESC LIMIT 1`,
68+
format: 'JSON',
69+
});
70+
const sampleJson = await sampleRes.json();
71+
sampleRow = sampleJson.data?.[0] ?? {};
72+
} else {
73+
const sampleRes = await this.client.query({
74+
query: `SELECT * FROM ${this.dbName}.${tableName} LIMIT 1`,
75+
format: 'JSON',
76+
});
77+
const sampleJson = await sampleRes.json();
78+
sampleRow = sampleJson.data?.[0] ?? {};
79+
}
80+
81+
return jsonResult.data.map((col: any) => ({
82+
name: col.name,
83+
sampleValue: sampleRow[col.name],
84+
}));
6185
}
6286

6387
async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> {

adminforth/dataConnectors/mongo.ts

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,32 +54,91 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
5454
return collections.map(col => col.name);
5555
}
5656

57-
async getAllColumnsInTable(collectionName: string): Promise<Array<string>> {
57+
async getAllColumnsInTable(collectionName: string): Promise<Array<{ name: string; type: string; isPrimaryKey?: boolean; sampleValue?: any; }>> {
5858

5959
const sampleDocs = await this.client.db().collection(collectionName).find({}).sort({ _id: -1 }).limit(100).toArray();
60-
61-
const fieldSet = new Set<string>();
62-
60+
61+
const fieldTypes = new Map<string, Set<string>>();
62+
const sampleValues = new Map<string, any>();
63+
64+
function detectType(value: any): string {
65+
if (value === null || value === undefined) return 'string';
66+
if (typeof value === 'string') return 'string';
67+
if (typeof value === 'boolean') return 'boolean';
68+
if (typeof value === 'number') {
69+
return Number.isInteger(value) ? 'integer' : 'float';
70+
}
71+
if (value instanceof Date) return 'datetime';
72+
if (typeof value === 'object') return 'json';
73+
return 'string';
74+
}
75+
76+
function addType(name: string, type: string) {
77+
if (!fieldTypes.has(name)) {
78+
fieldTypes.set(name, new Set());
79+
}
80+
fieldTypes.get(name)!.add(type);
81+
}
82+
6383
function flattenObject(obj: any, prefix = '') {
64-
Object.entries(obj).forEach(([key, value]) => {
84+
Object.entries(obj).forEach(([key, value]) => {
6585
const fullKey = prefix ? `${prefix}.${key}` : key;
66-
if (fullKey.startsWith('_id.') && typeof value !== 'string' && typeof value !== 'number') {
67-
return;
68-
}
69-
if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
70-
flattenObject(value, fullKey);
86+
87+
if (!fieldTypes.has(fullKey)) {
88+
fieldTypes.set(fullKey, new Set());
89+
sampleValues.set(fullKey, value);
90+
}
91+
92+
if (
93+
value instanceof Buffer ||
94+
(value && typeof value === 'object' && (value as any)._bsontype === 'Decimal128')
95+
) {
96+
addType(fullKey, 'json');
97+
return;
98+
}
99+
100+
if (
101+
value &&
102+
typeof value === 'object' &&
103+
!Array.isArray(value) &&
104+
!(value instanceof Date)
105+
) {
106+
addType(fullKey, 'json');
71107
} else {
72-
fieldSet.add(fullKey);
108+
addType(fullKey, detectType(value));
73109
}
74-
});
110+
});
75111
}
76-
112+
77113
for (const doc of sampleDocs) {
78-
flattenObject(doc);
114+
flattenObject(doc);
79115
}
80-
81-
return Array.from(fieldSet);
82-
}
116+
117+
return Array.from(fieldTypes.entries()).map(([name, types]) => {
118+
const primaryKey = name === '_id';
119+
120+
const priority = ['datetime', 'date', 'integer', 'float', 'boolean', 'json', 'string'];
121+
122+
const matched = priority.find(t => types.has(t)) || 'string';
123+
124+
const typeMap: Record<string, string> = {
125+
string: 'STRING',
126+
integer: 'INTEGER',
127+
float: 'FLOAT',
128+
boolean: 'BOOLEAN',
129+
datetime: 'DATETIME',
130+
date: 'DATE',
131+
json: 'JSON',
132+
};
133+
134+
return {
135+
name,
136+
type: typeMap[matched] ?? 'STRING',
137+
...(primaryKey ? { isPrimaryKey: true } : {}),
138+
sampleValue: sampleValues.get(name),
139+
};
140+
});
141+
}
83142

84143

85144
async discoverFields(resource) {

adminforth/dataConnectors/mysql.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,25 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
5050
return rows.map((row: any) => row.TABLE_NAME);
5151
}
5252

53-
async getAllColumnsInTable(tableName: string): Promise<Array<string>> {
54-
const [rows] = await this.client.query(
55-
`
56-
SELECT column_name
57-
FROM information_schema.columns
58-
WHERE table_name = ? AND table_schema = DATABASE();
59-
`,
60-
[tableName]
53+
async getAllColumnsInTable(tableName: string): Promise<Array<{ name: string; sampleValue?: any }>> {
54+
const [columns] = await this.client.query(
55+
`SELECT column_name FROM information_schema.columns WHERE table_name = ? AND table_schema = DATABASE()`,
56+
[tableName]
6157
);
62-
return rows.map((row: any) => row.COLUMN_NAME);
58+
59+
const columnNames = columns.map((c: any) => c.COLUMN_NAME);
60+
const orderByField = ['updated_at', 'created_at', 'id'].find(f => columnNames.includes(f));
61+
62+
let [rows] = orderByField
63+
? await this.client.query(`SELECT * FROM \`${tableName}\` ORDER BY \`${orderByField}\` DESC LIMIT 1`)
64+
: await this.client.query(`SELECT * FROM \`${tableName}\` LIMIT 1`);
65+
66+
const sampleRow = rows[0] || {};
67+
68+
return columns.map((col: any) => ({
69+
name: col.COLUMN_NAME,
70+
sampleValue: sampleRow[col.COLUMN_NAME],
71+
}));
6372
}
6473

6574
async discoverFields(resource) {

adminforth/dataConnectors/postgres.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,16 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa
5454
return res.rows.map(row => row.table_name);
5555
}
5656

57-
async getAllColumnsInTable(tableName: string): Promise<Array<string>> {
57+
async getAllColumnsInTable(tableName: string): Promise<Array<{ name: string; sampleValue?: any }>> {
5858
const res = await this.client.query(`
5959
SELECT column_name
6060
FROM information_schema.columns
6161
WHERE table_name = $1 AND table_schema = 'public';
6262
`, [tableName]);
63-
return res.rows.map(row => row.column_name);
64-
}
63+
const sampleRowRes = await this.client.query(`SELECT * FROM ${tableName} ORDER BY ctid DESC LIMIT 1`);
64+
const sampleRow = sampleRowRes.rows[0] ?? {};
65+
return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] }));
66+
}
6567

6668
async discoverFields(resource) {
6769

adminforth/dataConnectors/sqlite.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,25 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
1616
const rows = stmt.all();
1717
return rows.map((row) => row.name);
1818
}
19-
async getAllColumnsInTable(tableName: string): Promise<Array<string>> {
19+
async getAllColumnsInTable(tableName: string): Promise<Array<{ name: string; sampleValue?: any }>> {
2020
const stmt = this.client.prepare(`PRAGMA table_info(${tableName});`);
21-
const rows = stmt.all();
22-
return rows.map((row) => row.name);
21+
const columns = stmt.all();
22+
23+
const orderByField = columns.find(c => ['created_at', 'id'].includes(c.name))?.name;
24+
25+
let sampleRow = {};
26+
if (orderByField) {
27+
const rowStmt = this.client.prepare(`SELECT * FROM ${tableName} ORDER BY ${orderByField} DESC LIMIT 1`);
28+
sampleRow = rowStmt.get() || {};
29+
} else {
30+
const rowStmt = this.client.prepare(`SELECT * FROM ${tableName} LIMIT 1`);
31+
sampleRow = rowStmt.get() || {};
32+
}
33+
34+
return columns.map(col => ({
35+
name: col.name || '',
36+
sampleValue: sampleRow[col.name],
37+
}));
2338
}
2439

2540
async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> {

adminforth/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import SQLiteConnector from './dataConnectors/sqlite.js';
66
import CodeInjector from './modules/codeInjector.js';
77
import ExpressServer from './servers/express.js';
88
// import FastifyServer from './servers/fastify.js';
9-
import { ADMINFORTH_VERSION, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp } from './modules/utils.js';
9+
import { ADMINFORTH_VERSION, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp, isProbablyUUIDColumn } from './modules/utils.js';
1010
import {
1111
type AdminForthConfig,
1212
type IAdminForth,
@@ -444,8 +444,8 @@ class AdminForth implements IAdminForth {
444444

445445
async getAllColumnsInTable(
446446
tableName: string
447-
): Promise<{ [dataSourceId: string]: string[] }> {
448-
const results: { [dataSourceId: string]: string[] } = {};
447+
): Promise<{ [dataSourceId: string]: Array<{ name: string; type?: string; isPrimaryKey?: boolean; isUUID?: boolean; }> }> {
448+
const results: { [dataSourceId: string]: Array<{ name: string; type?: string; isPrimaryKey?: boolean; isUUID?: boolean; }> } = {};
449449

450450
if (!this.config.databaseConnectors) {
451451
this.config.databaseConnectors = { ...this.connectorClasses };
@@ -456,7 +456,10 @@ class AdminForth implements IAdminForth {
456456
if (typeof connector.getAllColumnsInTable === 'function') {
457457
try {
458458
const columns = await connector.getAllColumnsInTable(tableName);
459-
results[dataSourceId] = columns;
459+
results[dataSourceId] = columns.map(column => ({
460+
...column,
461+
isUUID: isProbablyUUIDColumn(column),
462+
}));
460463
} catch (err) {
461464
console.error(`Error getting columns for table ${tableName} in dataSource ${dataSourceId}:`, err);
462465
results[dataSourceId] = [];

adminforth/modules/utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,4 +490,13 @@ export class RAMLock {
490490
}
491491
}
492492
}
493+
}
494+
495+
export function isProbablyUUIDColumn(column: { name: string; type?: string; sampleValue?: any }): boolean {
496+
if (column.type?.toLowerCase() === 'uuid') return true;
497+
if (typeof column.sampleValue === 'string') {
498+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(column.sampleValue);
499+
}
500+
501+
return false;
493502
}

adminforth/types/Back.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export interface IAdminForthDataSourceConnector {
143143
/**
144144
* Function to get all columns in table.
145145
*/
146-
getAllColumnsInTable(tableName: string): Promise<Array<string>>;
146+
getAllColumnsInTable(tableName: string): Promise<Array<{ name: string; type?: string; isPrimaryKey?: boolean; sampleValue?: any; }>>;
147147
/**
148148
* Optional.
149149
* You an redefine this function to define how one record should be fetched from database.

0 commit comments

Comments
 (0)