diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 4ef6ea2fc..2294afe28 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -11,6 +11,18 @@ import { AdminForthFilterOperators, AdminForthSortDirections } from "../types/Co export default class AdminForthBaseConnector implements IAdminForthDataSourceConnectorBase { + + client: any; + + get db() { + console.warn('db is deprecated, use client instead'); + return this.client; + } + + setupClient(url: string): Promise { + throw new Error('Method not implemented.'); + } + getPrimaryKey(resource: AdminForthResource): string { for (const col of resource.dataSourceColumns) { if (col.primaryKey) { diff --git a/adminforth/dataConnectors/clickhouse.ts b/adminforth/dataConnectors/clickhouse.ts index 831347bad..9411818d8 100644 --- a/adminforth/dataConnectors/clickhouse.ts +++ b/adminforth/dataConnectors/clickhouse.ts @@ -6,37 +6,30 @@ import { createClient } from '@clickhouse/client' import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from '../types/Common.js'; class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForthDataSourceConnector { - - client: any; + dbName: string; url: string; - - /** - * url: http[s]://[username:password@]hostname:port[/database][?param1=value1¶m2=value2] - * @param param0 - */ - constructor({ url }: { url: string }) { - super(); - this.dbName = new URL(url).pathname.replace('/', ''); - this.url = url; - // create connection here - this.client = createClient({ - url: url.replace('clickhouse://', 'http://'), - clickhouse_settings: { - // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z') - date_time_input_format: 'best_effort', - - // Recommended for cluster usage to avoid situations where a query processing error occurred after the response code, - // and HTTP headers were already sent to the client. - // See https://clickhouse.com/docs/en/interfaces/http/#response-buffering - wait_end_of_query: 1, - }, - // log:{ - // level: ClickHouseLogLevel.TRACE, - // } - }); - - } + + async setupClient(url): Promise { + this.dbName = new URL(url).pathname.replace('/', ''); + this.url = url; + // create connection here + this.client = createClient({ + url: url.replace('clickhouse://', 'http://'), + clickhouse_settings: { + // Allows to insert serialized JS Dates (such as '2023-12-06T10:54:48.000Z') + date_time_input_format: 'best_effort', + + // Recommended for cluster usage to avoid situations where a query processing error occurred after the response code, + // and HTTP headers were already sent to the client. + // See https://clickhouse.com/docs/en/interfaces/http/#response-buffering + wait_end_of_query: 1, + }, + // log:{ + // level: ClickHouseLogLevel.TRACE, + // } + }); + } async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> { const tableName = resource.table; diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index 3ce2dedbb..3d8ae89d7 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -10,20 +10,18 @@ const escapeRegex = (value) => { }; class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataSourceConnector { - db: MongoClient - constructor({ url }: { url: string }) { - super(); - this.db = new MongoClient(url); + async setupClient(url): Promise { + this.client = new MongoClient(url); (async () => { try { - await this.db.connect(); - this.db.on('error', (err) => { + await this.client.connect(); + this.client.on('error', (err) => { console.log('Mongo error: ', err.message) - }); + }); console.log('Connected to Mongo'); } catch (e) { - console.error('ERROR: Failed to connect to Mongo', e); + throw new Error(`Failed to connect to Mongo: ${e}`); } })(); } @@ -133,7 +131,7 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS // const columns = resource.dataSourceColumns.filter(c=> !c.virtual).map((col) => col.name).join(', '); const tableName = resource.table; - const collection = this.db.db().collection(tableName); + const collection = this.client.db().collection(tableName); const query = await this.genQuery({ filters }); const sortArray: any[] = sort.map((s) => { @@ -154,7 +152,7 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS filters: { field: string, operator: AdminForthFilterOperators, value: any }[] }): Promise { - const collection = this.db.db().collection(resource.table); + const collection = this.client.db().collection(resource.table); const query = {}; for (const filter of filters) { query[filter.field] = this.OperatorsMap[filter.operator](filter.value); @@ -164,7 +162,7 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS async getMinMaxForColumnsWithOriginalTypes({ resource, columns }) { const tableName = resource.table; - const collection = this.db.db().collection(tableName); + const collection = this.client.db().collection(tableName); const result = {}; for (const column of columns) { result[column] = await collection @@ -178,7 +176,7 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS async createRecordOriginalValues({ resource, record }) { const tableName = resource.table; - const collection = this.db.db().collection(tableName); + const collection = this.client.db().collection(tableName); const columns = Object.keys(record); const newRecord = {}; for (const colName of columns) { @@ -188,19 +186,19 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS } async updateRecordOriginalValues({ resource, recordId, newValues }) { - const collection = this.db.db().collection(resource.table); + const collection = this.client.db().collection(resource.table); await collection.updateOne({ [this.getPrimaryKey(resource)]: recordId }, { $set: newValues }); } async deleteRecord({ resource, recordId }): Promise { const primaryKey = this.getPrimaryKey(resource); - const collection = this.db.db().collection(resource.table); + const collection = this.client.db().collection(resource.table); const res = await collection.deleteOne({ [primaryKey]: recordId }); return res.deletedCount > 0; } async close() { - await this.db.close() + await this.client.close() } } diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts new file mode 100644 index 000000000..2a7560bbf --- /dev/null +++ b/adminforth/dataConnectors/mysql.ts @@ -0,0 +1,297 @@ +import dayjs from 'dayjs'; +import { AdminForthResource, IAdminForthDataSourceConnector } from '../types/Back.js'; +import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; +import AdminForthBaseConnector from './baseConnector.js'; +import mysql from 'mysql2/promise'; + +class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataSourceConnector { + + async setupClient(url): Promise { + try { + this.client = await mysql.createConnection(url); + } catch (e) { + throw new Error(`Failed to connect to MySQL: ${e}`); + } + } + + OperatorsMap = { + [AdminForthFilterOperators.EQ]: '=', + [AdminForthFilterOperators.NE]: '<>', + [AdminForthFilterOperators.GT]: '>', + [AdminForthFilterOperators.LT]: '<', + [AdminForthFilterOperators.GTE]: '>=', + [AdminForthFilterOperators.LTE]: '<=', + [AdminForthFilterOperators.LIKE]: 'LIKE', + [AdminForthFilterOperators.ILIKE]: 'ILIKE', + [AdminForthFilterOperators.IN]: 'IN', + [AdminForthFilterOperators.NIN]: 'NOT IN', + }; + + SortDirectionsMap = { + [AdminForthSortDirections.asc]: 'ASC', + [AdminForthSortDirections.desc]: 'DESC', + }; + + async discoverFields(resource) { + const [results] = await this.client.query("SHOW COLUMNS FROM " + resource.table); + const fieldTypes = {}; + results.forEach((row) => { + const field: any = {}; + const baseType = row.Type.toLowerCase(); + if (baseType == 'tinyint(1)') { + field.type = AdminForthDataTypes.BOOLEAN; + field._underlineType = 'bool'; + } else if (baseType.startsWith('tinyint')) { + field.type = AdminForthDataTypes.INTEGER; + field._underlineType = 'tinyint'; + field.minValue = baseType.includes('unsigned') ? 0 : -128; + field.maxValue = baseType.includes('unsigned') ? 255 : 127; + } else if (baseType.startsWith('smallint')) { + field.type = AdminForthDataTypes.INTEGER; + field._underlineType = 'tinyint'; + field.minValue = baseType.includes('unsigned') ? 0 : -32768; + field.maxValue = baseType.includes('unsigned') ? 65535 : 32767; + } else if (baseType.startsWith('int') || baseType.endsWith('int')) { + field.type = AdminForthDataTypes.INTEGER; + field._underlineType = 'int'; + field.minValue = baseType.includes('unsigned') ? 0 : null; + } else if (baseType.startsWith('dec') || baseType.startsWith('numeric')) { + field.type = AdminForthDataTypes.DECIMAL; + field._underlineType = 'decimal'; + const [precision, scale] = baseType.match(/\d+/g); + field.precision = parseInt(precision); + field.scale = parseInt(scale); + field.minValue = baseType.includes('unsigned') ? 0 : null; + } else if (baseType.startsWith('float') || baseType.startsWith('double') || baseType.startsWith('real')) { + field.type = AdminForthDataTypes.FLOAT; + field._underlineType = 'float'; + field.minValue = baseType.includes('unsigned') ? 0 : null; + } else if (baseType.startsWith('varchar')) { + field.type = AdminForthDataTypes.STRING; + field._underlineType = 'varchar'; + const length = baseType.match(/\d+/); + field.maxLength = length ? parseInt(length[0]) : null; + } else if (baseType.startsWith('char')) { + field.type = AdminForthDataTypes.STRING; + field._underlineType = 'char'; + const length = baseType.match(/\d+/); + field.minLength = length ? parseInt(length[0]) : null; + field.maxLength = length ? parseInt(length[0]) : null; + } else if (baseType.endsWith('text')) { + field.type = AdminForthDataTypes.TEXT; + field._underlineType = 'text'; + } else if (baseType.startsWith('enum')) { + field.type = AdminForthDataTypes.STRING; + field._underlineType = 'enum'; + } else if (baseType.startsWith('json')) { + field.type = AdminForthDataTypes.JSON; + field._underlineType = 'json'; + } else if (baseType.startsWith('date')) { + field.type = AdminForthDataTypes.DATE; + field._underlineType = 'date'; + } else if (baseType.startsWith('time')) { + field.type = AdminForthDataTypes.TIME; + field._underlineType = 'time'; + } else if (baseType.startsWith('datetime') || baseType.startsWith('timestamp')) { + field.type = AdminForthDataTypes.DATETIME; + field._underlineType = 'timestamp'; + } else if (baseType.startsWith('year')) { + field.type = AdminForthDataTypes.INTEGER; + field._underlineType = 'year'; + field.minValue = 1901; + field.maxValue = 2155; + } else { + field.type = 'unknown' + } + field._baseTypeDebug = baseType; + field.primaryKey = row.Key === 'PRI'; + field.default = row.Default; + field.required = row.Null === 'NO' && !row.Default; + fieldTypes[row.Field] = field + }); + return fieldTypes; + } + + getFieldValue(field, value) { + if (field.type == AdminForthDataTypes.DATETIME) { + if (!value) { + return null; + } + return dayjs(value).toISOString(); + } else if (field.type == AdminForthDataTypes.DATE) { + if (!value) { + return null; + } + return dayjs(value).toISOString().split('T')[0]; + } else if (field.type == AdminForthDataTypes.TIME) { + if (!value) { + return null; + } + return dayjs(value).toISOString().split('T')[1]; + } else if (field.type == AdminForthDataTypes.BOOLEAN) { + return !!value; + } else if (field.type == AdminForthDataTypes.JSON) { + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch (e) { + return {'error': `Failed to parse JSON: ${e.message}`} + } + } else if (typeof value === 'object') { + return value; + } else { + console.error('JSON field value is not string or object, but has type:', typeof value); + console.error('Field:', field); + return {} + } + } + + return value; + } + + + setFieldValue(field, value) { + if (field.type == AdminForthDataTypes.DATETIME) { + if (!value) { + return null; + } + return dayjs(value).format('YYYY-MM-DD HH:mm:ss'); + } else if (field.type == AdminForthDataTypes.BOOLEAN) { + return value ? 1 : 0; + } else if (field.type == AdminForthDataTypes.JSON) { + if (field._underlineType === 'json') { + return value; + } else { + return JSON.stringify(value); + } + } + return value; + } + + whereClauseAndValues(resource: AdminForthResource, filters: { field: string, operator: AdminForthFilterOperators, value: any }[]) : { + sql: string, + values: any[], + } { + const where = filters.length ? `WHERE ${filters.map((f, i) => { + let placeholder = '?'; + let field = f.field; + let operator = this.OperatorsMap[f.operator]; + if (f.operator == AdminForthFilterOperators.IN || f.operator == AdminForthFilterOperators.NIN) { + placeholder = `(${f.value.map(() => '?').join(', ')})`; + } else if (f.operator == AdminForthFilterOperators.ILIKE) { + placeholder = `LOWER(?)`; + field = `LOWER(${f.field})`; + operator = 'LIKE'; + } + return `${field} ${operator} ${placeholder}`; + }).join(' AND ')}` : ''; + + const filterValues = []; + filters.length ? filters.forEach((f) => { + // for arrays do set in map + let v = f.value; + + if (f.operator == AdminForthFilterOperators.LIKE || f.operator == AdminForthFilterOperators.ILIKE) { + filterValues.push(`%${v}%`); + } else if (f.operator == AdminForthFilterOperators.IN || f.operator == AdminForthFilterOperators.NIN) { + filterValues.push(...v); + } else { + filterValues.push(v); + } + }) : []; + return { + sql: where, + values: filterValues, + }; + } + + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise { + const columns = resource.dataSourceColumns.map((col) => `${col.name}`).join(', '); + const tableName = resource.table; + + const { sql: where, values: filterValues } = this.whereClauseAndValues(resource, filters); + + const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; + let selectQuery = `SELECT ${columns} FROM ${tableName}`; + if (where) selectQuery += ` ${where}`; + if (orderBy) selectQuery += ` ${orderBy}`; + if (limit) selectQuery += ` LIMIT ${limit}`; + if (offset) selectQuery += ` OFFSET ${offset}`; + if (process.env.HEAVY_DEBUG_QUERY) { + console.log('🪲📜 MySQL Q:', selectQuery, 'values:', filterValues); + } + const [results] = await this.client.execute(selectQuery, filterValues); + return results.map((row) => { + const newRow = {}; + for (const [key, value] of Object.entries(row)) { + newRow[key] = value; + } + return newRow; + }); + } + + async getCount({ resource, filters }: { resource: AdminForthResource; filters: { field: string, operator: AdminForthFilterOperators, value: any }[]; }): Promise { + const tableName = resource.table; + const { sql: where, values: filterValues } = this.whereClauseAndValues(resource, filters); + const q = `SELECT COUNT(*) FROM ${tableName} ${where}`; + if (process.env.HEAVY_DEBUG_QUERY) { + console.log('🪲📜 MySQL Q:', q, 'values:', filterValues); + } + const [results] = await this.client.query(q, filterValues); + return +results[0].count; + } + + async getMinMaxForColumnsWithOriginalTypes({ resource, columns }) { + const tableName = resource.table; + const result = {}; + await Promise.all(columns.map(async (col) => { + const q = `SELECT MIN(${col.name}) as min, MAX(${col.name}) as max FROM ${tableName}`; + if (process.env.HEAVY_DEBUG_QUERY) { + console.log('🪲📜 MySQL Q:', q); + } + const [results] = await this.client.query(q); + const { min, max } = results[0]; + result[col.name] = { + min, max, + }; + })) + return result; + } + + async createRecordOriginalValues({ resource, record }) { + const tableName = resource.table; + const columns = Object.keys(record); + const placeholders = columns.map(() => '?').join(', '); + const values = columns.map((colName) => typeof record[colName] === 'undefined' ? null : record[colName]); + const q = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`; + if (process.env.HEAVY_DEBUG_QUERY) { + console.log('🪲📜 MySQL Q:', q, 'values:', values); + } + await this.client.execute(q, values); + } + + async updateRecordOriginalValues({ resource, recordId, newValues }) { + const values = [...Object.values(newValues), recordId]; + const columnsWithPlaceholders = Object.keys(newValues).map((col, i) => `${col} = ?`).join(', '); + const q = `UPDATE ${resource.table} SET ${columnsWithPlaceholders} WHERE ${this.getPrimaryKey(resource)} = ?`; + if (process.env.HEAVY_DEBUG_QUERY) { + console.log('🪲📜 MySQL Q:', q, 'values:', values); + } + await this.client.execute(q, values); + } + + async deleteRecord({ resource, recordId }): Promise { + const q = `DELETE FROM ${resource.table} WHERE ${this.getPrimaryKey(resource)} = ?`; + if (process.env.HEAVY_DEBUG_QUERY) { + console.log('🪲📜 MySQL Q:', q, 'values:', [recordId]); + } + const res = await this.client.execute(q, [recordId]); + return res.rowCount > 0; + } + + async close() { + await this.client.end(); + } +} + +export default MysqlConnector; \ No newline at end of file diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 59294cae9..ab29b4009 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -8,25 +8,21 @@ const { Client } = pkg; class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDataSourceConnector { - db: any; - - constructor({ url }) { - super(); - this.db = new Client({ + async setupClient(url: string): Promise { + this.client = new Client({ connectionString: url }); - (async () => { - try { - await this.db.connect(); - this.db.on('error', (err) => { - console.log('Postgres error: ', err.message, err.stack) - this.db.end(); - this.db = new PostgresConnector({ url }).db; - }); - } catch (e) { - console.error('ERROR: Failed to connect to Postgres', e); - } - })(); + try { + await this.client.connect(); + this.client.on('error', async (err) => { + console.log('Postgres error: ', err.message, err.stack) + this.client.end(); + await new Promise((resolve) => { setTimeout(resolve, 1000) }); + this.setupClient(url); + }); + } catch (e) { + throw new Error(`Failed to connect to Postgres ${e}`); + } } OperatorsMap = { @@ -50,7 +46,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa async discoverFields(resource) { const tableName = resource.table; - const stmt = await this.db.query(` + const stmt = await this.client.query(` SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, @@ -266,7 +262,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa if (process.env.HEAVY_DEBUG_QUERY) { console.log('🪲📜 PG Q:', selectQuery, 'params:', d); } - const stmt = await this.db.query(selectQuery, d); + const stmt = await this.client.query(selectQuery, d); const rows = stmt.rows; return rows.map((row) => { const newRow = {}; @@ -284,7 +280,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa if (process.env.HEAVY_DEBUG_QUERY) { console.log('🪲📜 PG Q:', q, 'values:', filterValues); } - const stmt = await this.db.query(q, filterValues); + const stmt = await this.client.query(q, filterValues); return +stmt.rows[0].count; } @@ -296,7 +292,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa if (process.env.HEAVY_DEBUG_QUERY) { console.log('🪲📜 PG Q:', q); } - const stmt = await this.db.query(q); + const stmt = await this.client.query(q); const { min, max } = stmt.rows[0]; result[col.name] = { min, max, @@ -317,7 +313,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa if (process.env.HEAVY_DEBUG_QUERY) { console.log('🪲📜 PG Q:', q, 'values:', values); } - await this.db.query(q, values); + await this.client.query(q, values); } async updateRecordOriginalValues({ resource, recordId, newValues }) { @@ -327,7 +323,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa if (process.env.HEAVY_DEBUG_QUERY) { console.log('🪲📜 PG Q:', q, 'values:', values); } - await this.db.query(q, values); + await this.client.query(q, values); } async deleteRecord({ resource, recordId }): Promise { @@ -335,12 +331,12 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa if (process.env.HEAVY_DEBUG_QUERY) { console.log('🪲📜 PG Q:', q, 'values:', [recordId]); } - const res = await this.db.query(q, [recordId]); + const res = await this.client.query(q, [recordId]); return res.rowCount > 0; } async close() { - await this.db.end(); + await this.client.end(); } } diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 68fb84295..07641ce78 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -6,17 +6,13 @@ import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirection class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthDataSourceConnector { - db: any; - - constructor({ url }: { url: string }) { - super(); - // create connection here - this.db = betterSqlite3(url.replace('sqlite://', '')); - } + async setupClient(url: string): Promise { + this.client = betterSqlite3(url.replace('sqlite://', '')); + } async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> { const tableName = resource.table; - const stmt = this.db.prepare(`PRAGMA table_info(${tableName})`); + const stmt = this.client.prepare(`PRAGMA table_info(${tableName})`); const rows = await stmt.all(); const fieldTypes = {}; rows.forEach((row) => { @@ -197,7 +193,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; const q = `SELECT ${columns} FROM ${tableName} ${where} ${orderBy} LIMIT ? OFFSET ?`; - const stmt = this.db.prepare(q); + const stmt = this.client.prepare(q); const d = [...filterValues, limit, offset]; if (process.env.HEAVY_DEBUG_QUERY) { @@ -222,7 +218,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData if (process.env.HEAVY_DEBUG_QUERY) { console.log('🪲📜 SQLITE Q', q, 'params:', filterValues); } - const totalStmt = this.db.prepare(q); + const totalStmt = this.client.prepare(q); return totalStmt.get([...filterValues])['COUNT(*)']; } @@ -230,7 +226,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData const tableName = resource.table; const result = {}; await Promise.all(columns.map(async (col) => { - const stmt = await this.db.prepare(`SELECT MIN(${col.name}) as min, MAX(${col.name}) as max FROM ${tableName}`); + const stmt = await this.client.prepare(`SELECT MIN(${col.name}) as min, MAX(${col.name}) as max FROM ${tableName}`); const { min, max } = stmt.get(); result[col.name] = { min, max, @@ -244,7 +240,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData const columns = Object.keys(record); const placeholders = columns.map(() => '?').join(', '); const values = columns.map((colName) => record[colName]); - const q = this.db.prepare(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`) + const q = this.client.prepare(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`); await q.run(values); } @@ -255,18 +251,18 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData if (process.env.HEAVY_DEBUG_QUERY) { console.log('🪲📜 SQLITE Q', q, 'params:', values); } - const query = this.db.prepare(q); + const query = this.client.prepare(q); await query.run(values); } async deleteRecord({ resource, recordId }: { resource: AdminForthResource, recordId: any }): Promise { - const q = this.db.prepare(`DELETE FROM ${resource.table} WHERE ${this.getPrimaryKey(resource)} = ?`); + const q = this.client.prepare(`DELETE FROM ${resource.table} WHERE ${this.getPrimaryKey(resource)} = ?`); const res = await q.run(recordId); return res.changes > 0; } close() { - this.db.close(); + this.client.close(); } } diff --git a/adminforth/index.ts b/adminforth/index.ts index 7acf79efa..b000b2335 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -2,6 +2,7 @@ import AdminForthAuth from './auth.js'; import MongoConnector from './dataConnectors/mongo.js'; import PostgresConnector from './dataConnectors/postgres.js'; +import MysqlConnector from './dataConnectors/mysql.js'; import SQLiteConnector from './dataConnectors/sqlite.js'; import CodeInjector from './modules/codeInjector.js'; import ExpressServer from './servers/express.js'; @@ -288,6 +289,7 @@ class AdminForth implements IAdminForth { 'postgresql': PostgresConnector, 'mongodb': MongoConnector, 'clickhouse': ClickhouseConnector, + 'mysql': MysqlConnector, }; if (!this.config.databaseConnectors) { this.config.databaseConnectors = {...this.connectorClasses}; @@ -297,9 +299,17 @@ class AdminForth implements IAdminForth { if (!this.config.databaseConnectors[dbType]) { throw new Error(`Database type '${dbType}' is not supported, consider using one of ${Object.keys(this.connectorClasses).join(', ')} or create your own data-source connector`); } - this.connectors[ds.id] = new this.config.databaseConnectors[dbType]({url: ds.url}); + this.connectors[ds.id] = new this.config.databaseConnectors[dbType](); }); + await Promise.all(Object.keys(this.connectors).map(async (dataSourceId) => { + try { + await this.connectors[dataSourceId].setupClient(this.config.dataSources.find((ds) => ds.id === dataSourceId).url); + } catch (e) { + console.error(`Error while connecting to datasource '${dataSourceId}':`, e); + } + })); + await Promise.all(this.config.resources.map(async (res) => { if (!this.connectors[res.dataSource]) { const similar = suggestIfTypo(Object.keys(this.connectors), res.dataSource); diff --git a/adminforth/package-lock.json b/adminforth/package-lock.json index a95532181..15421e077 100644 --- a/adminforth/package-lock.json +++ b/adminforth/package-lock.json @@ -30,6 +30,7 @@ "jsonwebtoken": "^9.0.2", "listr2": "^8.2.5", "mongodb": "6.6", + "mysql2": "^3.12.0", "node-fetch": "^3.3.2", "pg": "^8.11.5", "ws": "^8.18.0" @@ -1065,6 +1066,14 @@ "dev": true, "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", @@ -1869,6 +1878,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2627,6 +2644,14 @@ "node": ">=10" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3333,6 +3358,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -3819,6 +3849,11 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.4.tgz", + "integrity": "sha512-qtzLbJE8hq7VabR3mISmVGtoXP8KGc2Z/AT8OuqlYD7JTR3oqrgwdjnk07wpj1twXxYmgDXgoKVWUG/fReSzHg==" + }, "node_modules/longest-streak": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", @@ -3837,6 +3872,20 @@ "dev": true, "license": "ISC" }, + "node_modules/lru.min": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.1.tgz", + "integrity": "sha512-FbAj6lXil6t8z4z3j0E5mfRlPzxkySotzUHwRXjlpRh10vc6AI6WN62ehZj82VG7M20rqogJ0GLwar2Xa05a8Q==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/markdown-table": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", @@ -4412,6 +4461,36 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mysql2": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", + "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4424,6 +4503,25 @@ "thenify-all": "^1.0.0" } }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -8469,6 +8567,11 @@ "node": ">= 0.8" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -8849,6 +8952,14 @@ "node": ">= 10.x" } }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/adminforth/package.json b/adminforth/package.json index 4e7d00aa9..5168b9fa7 100644 --- a/adminforth/package.json +++ b/adminforth/package.json @@ -78,6 +78,7 @@ "jsonwebtoken": "^9.0.2", "listr2": "^8.2.5", "mongodb": "6.6", + "mysql2": "^3.12.0", "node-fetch": "^3.3.2", "pg": "^8.11.5", "ws": "^8.18.0" diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index 896c6ba55..33b0675af 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -118,6 +118,14 @@ export interface IAdminForthSort { } export interface IAdminForthDataSourceConnector { + + client: any; + + /** + * Function to setup client connection to database. + * @param url URL to database. Examples: clickhouse://demo:demo@localhost:8125/demo + */ + setupClient(url: string): Promise; /** * Optional. @@ -250,7 +258,7 @@ export interface IAdminForthDataSourceConnectorBase extends IAdminForthDataSourc export interface IAdminForthDataSourceConnectorConstructor { - new ({ url }: { url: string }): IAdminForthDataSourceConnectorBase; + new (): IAdminForthDataSourceConnectorBase; } export interface IAdminForthAuth { diff --git a/dev-demo/index.ts b/dev-demo/index.ts index 360e7b0a3..6fd5591d1 100644 --- a/dev-demo/index.ts +++ b/dev-demo/index.ts @@ -5,6 +5,7 @@ import AdminForth, { AdminUser, Filters } from '../adminforth/index.js'; import AuditLogPlugin from '../plugins/adminforth-audit-log/index.js'; import clicksResource from './resources/clicks.js'; import apartmentsResource from './resources/apartments.js'; +import apartmentBuyersResource from './resources/apartment_buyers.js'; import auditLogResource from './resources/audit_log.js'; import descriptionImageResource from './resources/description_image.js'; import usersResource from './resources/users.js'; @@ -189,13 +190,17 @@ export const admin = new AdminForth({ { id: 'ch', url: 'clickhouse://demo:demo@localhost:8125/demo', - - } + }, + { + id: 'mysql', + url: 'mysql://demo:demo@localhost:3307/demo', + }, ], resources: [ clicksResource, auditLogResource, apartmentsResource, + apartmentBuyersResource, usersResource, descriptionImageResource, // gamesResource, @@ -228,6 +233,11 @@ export const admin = new AdminForth({ return '10' } }, + { + label: 'Potential Buyers', + icon: 'flowbite:user-solid', + resourceId: 'apartment_buyers', + }, { label: 'Description Images', resourceId: 'description_images', diff --git a/dev-demo/inventory.yml b/dev-demo/inventory.yml index 990fba05f..ee084868b 100644 --- a/dev-demo/inventory.yml +++ b/dev-demo/inventory.yml @@ -42,7 +42,21 @@ services: volumes: - mongo-data1:/data/db + mysql: + image: mysql + restart: always + ports: + - "3307:3306" + environment: + MYSQL_ROOT_PASSWORD: demo + MYSQL_DATABASE: demo + MYSQL_USER: demo + MYSQL_PASSWORD: demo + volumes: + - mysql-data:/var/lib/mysql + volumes: clickhouse-data: pg-data: - mongo-data1: \ No newline at end of file + mongo-data1: + mysql-data: \ No newline at end of file diff --git a/dev-demo/resources/apartment_buyers.ts b/dev-demo/resources/apartment_buyers.ts new file mode 100644 index 000000000..60adb0c1b --- /dev/null +++ b/dev-demo/resources/apartment_buyers.ts @@ -0,0 +1,179 @@ +import AdminForth, { + AdminForthDataTypes, + AdminForthResourcePages, + AdminForthResource, + AdminForthResourceColumn, + AdminForthResourceInput, + AdminUser, +} from "../../adminforth"; +import { v1 as uuid } from "uuid"; +import RichEditorPlugin from "../../plugins/adminforth-rich-editor"; + +export default { + dataSource: 'mysql', + table: 'apartment_buyers', + /* + To create table run SQL in MySQL Workbench or similar tool: + CREATE TABLE apartment_buyers ( + id VARCHAR(255) NOT NULL, + created_at DATETIME, + name VARCHAR(255) NOT NULL, + age INT UNSIGNED, + gender ENUM('m', 'f'), + info LONGTEXT, + contact_info JSON, + language: CHAR(2), + ideal_price DECIMAL(65, 2), + ideal_space FLOAT(24), + ideal_subway_distance FLOAT(53), + contacted BOOL NOT NULL, + contact_date DATE, + contact_time TIME, + realtor_id VARCHAR(255), + PRIMARY KEY (id) + ); + */ + resourceId: 'apartment_buyers', + label: 'Potential Buyers', + recordLabel: (r: any) => `👤 ${r.name}`, + columns: [ + { + name: 'id', + label: 'ID', + primaryKey: true, + fillOnCreate: ({ initialRecord, adminUser }: any) => uuid(), + showIn: { + create: false, + edit: false, + }, + components: { + list: "@/renderers/CompactUUID.vue", + }, + }, + { + name: "created_at", + type: AdminForthDataTypes.DATETIME, + allowMinMaxQuery: true, + showIn: { + [AdminForthResourcePages.create]: false, + }, + components: { + list: "@/renderers/RelativeTime.vue", + }, + fillOnCreate: ({ initialRecord, adminUser }: any) => new Date().toISOString(), + }, + { + name: 'name', + required: true, + maxLength: 255, + }, + { + name: 'age', + type: AdminForthDataTypes.INTEGER, + minValue: 18, + }, + { + name: 'gender', + enum: [ + { + value: 'm', + label: 'Male', + }, + { + value: 'f', + label: 'Female', + }, + { + value: null, + label: 'Unknown', + }, + ], + }, + { + name: 'language', + enum: [ + { value: 'en', label: 'English' }, + { value: 'uk', label: 'Ukrainian' }, + { value: 'fr', label: 'French' }, + { value: 'es', label: 'Spanish' }, + ], + }, + { + name: 'info', + sortable: false, + type: AdminForthDataTypes.RICHTEXT, + showIn: { list: false }, + }, + { + name: 'contact_info', + sortable: false, + type: AdminForthDataTypes.JSON, + showIn: { list: false }, + }, + { + name: 'ideal_price', + type: AdminForthDataTypes.DECIMAL, + showIn: { list: false }, + }, + { + name: 'ideal_space', + type: AdminForthDataTypes.FLOAT, + showIn: { list: false }, + }, + { + name: 'ideal_subway_distance', + type: AdminForthDataTypes.FLOAT, + showIn: { list: false }, + }, + { + name: 'contacted', + type: AdminForthDataTypes.BOOLEAN, + required: true, + }, + { + name: 'contact_date', + type: AdminForthDataTypes.DATE, + showIn: { list: false }, + }, + { + name: 'contact_time', + type: AdminForthDataTypes.TIME, + showIn: { list: false }, + }, + { + name: 'realtor_id', + foreignResource: { + resourceId: 'users', + }, + }, + ], + plugins: [ + new RichEditorPlugin({ + htmlFieldName: 'info', + }), + ], + options: { + fieldGroups: [ + { + groupName: "Personal Information", + columns: ["name", "age", "gender", "language", "info", "contact_info"], + }, + { + groupName: "Preferences", + columns: [ + "ideal_price", + "ideal_space", + "ideal_subway_distance", + ], + }, + { + groupName: "Realtor Related Information", + columns: ["contacted", "contact_date", "contact_time", "realtor_id"], + }, + { + groupName: "System Information", + columns: ["id", "created_at"], + }, + ], + }, +} as AdminForthResourceInput; \ No newline at end of file diff --git a/dev-demo/schema.prisma b/dev-demo/schema.prisma index 0ab3726e5..8538c8d63 100644 --- a/dev-demo/schema.prisma +++ b/dev-demo/schema.prisma @@ -85,4 +85,22 @@ model translations { // we need both indexes on en_string+category and separately on category @@index([en_string, category]) @@index([category]) +} + +model apartment_buyers { + id String @id + created_at DateTime + name String + age Int? + gender String? + info String? + contact_info String? + language String? + ideal_price Decimal? + ideal_space Float? + ideal_subway_distance Float? + contacted Boolean @default(false) + contact_date Date? + contact_time Time? + realtor_id String? } \ No newline at end of file