diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 7faddd4c..9a9a0079 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -1,7 +1,8 @@ import { AdminForthResource, IAdminForthDataSourceConnectorBase, AdminForthResourceColumn, - IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter + IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter, + Filters } from "../types/Back.js"; @@ -39,7 +40,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon limit: 1, offset: 0, sort: [], - filters: { operator: AdminForthFilterOperators.AND, subFilters: [{ field: this.getPrimaryKey(resource), operator: AdminForthFilterOperators.EQ, value: id }]}, + filters: Filters.AND(Filters.EQ(this.getPrimaryKey(resource), id)) }); return data.length > 0 ? data[0] : null; } @@ -47,14 +48,14 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon validateAndNormalizeInputFilters(filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array | undefined): IAdminForthAndOrFilter { if (!filter) { // if no filter, return empty "and" filter - return { operator: AdminForthFilterOperators.AND, subFilters: [] }; + return Filters.AND(); } if (typeof filter !== 'object') { throw new Error(`Filter should be an array or an object`); } if (Array.isArray(filter)) { // if filter is an array, combine them using "and" operator - return { operator: AdminForthFilterOperators.AND, subFilters: filter }; + return Filters.AND(...filter); } if ((filter as IAdminForthAndOrFilter).subFilters) { // if filter is already AndOr filter - return as is @@ -62,7 +63,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon } // by default, assume filter is Single filter, turn it into AndOr filter - return { operator: AdminForthFilterOperators.AND, subFilters: [filter] }; + return Filters.AND(filter); } validateAndNormalizeFilters(filters: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, resource: AdminForthResource): { ok: boolean, error: string } { @@ -82,12 +83,11 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon const column = resource.dataSourceColumns.find((col) => col.name == (f as IAdminForthSingleFilter).field); // console.log(`\n~~~ column: ${JSON.stringify(column, null, 2)}\n~~~ resource.columns: ${JSON.stringify(resource.dataSourceColumns, null, 2)}\n~~~ filter: ${JSON.stringify(f, null, 2)}\n`); if (column.isArray?.enabled && (column.enum || column.foreignResource)) { - filters[fIndex] = { - operator: AdminForthFilterOperators.OR, - subFilters: f.value.map((v: any) => { - return { field: column.name, operator: AdminForthFilterOperators.LIKE, value: v }; - }), - }; + filters[fIndex] = Filters.OR( + ...f.value.map((v: any) => { + return Filters.LIKE(column.name, v); + }) + ); } } @@ -318,13 +318,10 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon const primaryKeyField = this.getPrimaryKey(resource); const existingRecord = await this.getData({ resource, - filters: { - operator: AdminForthFilterOperators.AND, - subFilters: [ - { field: column.name, operator: AdminForthFilterOperators.EQ, value }, - ...(record ? [{ field: primaryKeyField, operator: AdminForthFilterOperators.NE as AdminForthFilterOperators.NE, value: record[primaryKeyField] }] : []) - ] - }, + filters: Filters.AND( + Filters.EQ(column.name, value), + ...(record ? [Filters.NEQ(primaryKeyField, record[primaryKeyField])] : []) + ), limit: 1, sort: [], offset: 0, @@ -489,11 +486,14 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon getRecordByPrimaryKey(resource: AdminForthResource, recordId: string): Promise { return this.getRecordByPrimaryKeyWithOriginalTypes(resource, recordId).then((record) => { - const newRecord = {}; - for (const col of resource.dataSourceColumns) { - newRecord[col.name] = this.getFieldValue(col, record[col.name]); - } - return newRecord; + if (!record) { + return null; + } + const newRecord = {}; + for (const col of resource.dataSourceColumns) { + newRecord[col.name] = this.getFieldValue(col, record[col.name]); + } + return newRecord; }); } async getAllTables(): Promise { diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index 7899f033..27d48806 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -1,14 +1,39 @@ import dayjs from 'dayjs'; -import { MongoClient } from 'mongodb'; -import { Decimal128, Double } from 'bson'; -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource } from '../types/Back.js'; +import { MongoClient, BSON, ObjectId, Decimal128, Double, UUID } from 'mongodb'; +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, Filters } from '../types/Back.js'; import AdminForthBaseConnector from './baseConnector.js'; - import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; +const UUID36 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX24 = /^[0-9a-f]{24}$/i; // 24-hex (Mongo ObjectId) + +function idToString(v: any) { + if (v == null) return null; + if (typeof v === "string" || typeof v === "number" || typeof v === "bigint") return String(v); + + const s = BSON.EJSON.serialize(v); + if (s && typeof s === "object") { + if ("$oid" in s) { + return String(s.$oid); + } + if ("$uuid" in s) { + return String(s.$uuid); + } + } + return String(v); +} + +const extractSimplePkEq = (f: any, pk: string): string | null => { + while (f?.subFilters?.length === 1) f = f.subFilters[0]; + return (f?.operator === AdminForthFilterOperators.EQ && f?.field === pk && f.value != null && typeof f.value !== "object") + ? String(f.value) + : null; +}; + const escapeRegex = (value) => { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Escapes special characters }; + function normalizeMongoValue(v: any) { if (v == null) { return v; @@ -29,7 +54,13 @@ function normalizeMongoValue(v: any) { } class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataSourceConnector { - + private pkCandidates(pkValue: any): any[] { + if (pkValue == null || typeof pkValue !== "string") return [pkValue]; + const candidates: any[] = [pkValue]; + try { candidates.push(new UUID(pkValue)); } catch(err) { console.error(`Failed to create UUID from ${pkValue}: ${err.message}`); } + try { candidates.push(new ObjectId(pkValue)); } catch(err) { console.error(`Failed to create ObjectId from ${pkValue}: ${err.message}`); } + return candidates; + } async setupClient(url): Promise { this.client = new MongoClient(url); (async () => { @@ -183,72 +214,45 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS }, {}); } - getPrimaryKey(resource) { - for (const col of resource.dataSourceColumns) { - if (col.primaryKey) { - return col.name; - } - } - } - getFieldValue(field, value) { - if (field.type == AdminForthDataTypes.DATETIME) { - if (!value) { - return null; - } - return dayjs(Date.parse(value)).toISOString(); - - } else if (field.type == AdminForthDataTypes.DATE) { - if (!value) { - return null; - } - return dayjs(Date.parse(value)).toISOString().split('T')[0]; - - } else if (field.type == AdminForthDataTypes.BOOLEAN) { - return value === null ? null : !!value; - } else if (field.type == AdminForthDataTypes.DECIMAL) { - if (value === null || value === undefined) { - return null; - } - return value?.toString(); + if (field.type === AdminForthDataTypes.DATETIME) { + return value ? dayjs(Date.parse(value)).toISOString() : null; + } + if (field.type === AdminForthDataTypes.DATE) { + return value ? dayjs(Date.parse(value)).toISOString().split("T")[0] : null; + } + if (field.type === AdminForthDataTypes.BOOLEAN) { + return value === null ? null : !!value; + } + if (field.type === AdminForthDataTypes.DECIMAL) { + return value === null || value === undefined ? null : value.toString(); + } + if (field.name === '_id') { + return idToString(value); } - return value; } setFieldValue(field, value) { - if (value === undefined) return undefined; - if (value === null) return null; - + if (value === undefined) { + return undefined; + } + if (value === null || value === '') { + return null; + } if (field.type === AdminForthDataTypes.DATETIME) { - if (value === "" || value === null) { - return null; - } return dayjs(value).isValid() ? dayjs(value).toDate() : null; } - if (field.type === AdminForthDataTypes.INTEGER) { - if (value === "" || value === null) { - return null; - } return Number.isFinite(value) ? Math.trunc(value) : null; } - if (field.type === AdminForthDataTypes.FLOAT) { - if (value === "" || value === null) { - return null; - } return Number.isFinite(value) ? value : null; } - if (field.type === AdminForthDataTypes.DECIMAL) { - if (value === "" || value === null) { - return null; - } return value.toString(); } - return value; } @@ -299,34 +303,54 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS .map((f) => this.getFilterQuery(resource, f))); } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: - { - resource: AdminForthResource, - limit: number, - offset: number, - sort: { field: string, direction: AdminForthSortDirections }[], + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: + { + resource: AdminForthResource, + limit: number, + offset: number, + sort: { field: string, direction: AdminForthSortDirections }[], filters: IAdminForthAndOrFilter, } ): Promise { // const columns = resource.dataSourceColumns.filter(c=> !c.virtual).map((col) => col.name).join(', '); const tableName = resource.table; + const collection = this.client.db().collection(tableName); - const collection = this.client.db().collection(tableName); + const pk = this.getPrimaryKey(resource); + const pkValue = extractSimplePkEq(filters, pk); + + if (pkValue !== null) { + let res = await collection.find({ [pk]: pkValue }).limit(1).toArray(); + if (res.length) { + return res; + } + if (UUID36.test(pkValue)) { + res = await collection.find({ [pk]: new UUID(pkValue) }).limit(1).toArray(); + } + if (res.length) { + return res; + } + if (HEX24.test(pkValue)) { + res = await collection.find({ [pk]: new ObjectId(pkValue) }).limit(1).toArray(); + } + if (res.length) { + return res; + } + + return []; + } + const query = filters.subFilters.length ? this.getFilterQuery(resource, filters) : {}; - const sortArray: any[] = sort.map((s) => { - return [s.field, this.SortDirectionsMap[s.direction]]; - }); + const sortArray: any[] = sort.map((s) => [s.field, this.SortDirectionsMap[s.direction]]); - const result = await collection.find(query) + return await collection.find(query) .sort(sortArray) .skip(offset) .limit(limit) .toArray(); - - return result } async getCount({ resource, filters }: { @@ -380,14 +404,22 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS async updateRecordOriginalValues({ resource, recordId, newValues }) { const collection = this.client.db().collection(resource.table); - await collection.updateOne({ [this.getPrimaryKey(resource)]: recordId }, { $set: newValues }); + const pk = this.getPrimaryKey(resource); + for (const id of this.pkCandidates(recordId)) { + const res = await collection.updateOne({ [pk]: id }, { $set: newValues }); + if (res.matchedCount > 0) return; + } + throw new Error(`Record with id ${recordId} not found in resource ${resource.name}`); } async deleteRecord({ resource, recordId }): Promise { - const primaryKey = this.getPrimaryKey(resource); const collection = this.client.db().collection(resource.table); - const res = await collection.deleteOne({ [primaryKey]: recordId }); - return res.deletedCount > 0; + const pk = this.getPrimaryKey(resource); + for (const id of this.pkCandidates(recordId)) { + const res = await collection.deleteOne({ [pk]: id }); + if (res.deletedCount > 0) return true; + } + return false; } async close() { diff --git a/dev-demo/index.ts b/dev-demo/index.ts index 1f46904d..193747c3 100644 --- a/dev-demo/index.ts +++ b/dev-demo/index.ts @@ -10,7 +10,7 @@ import cars_MyS_resource from './resources/cars_MyS.js'; import cars_PG_resource from './resources/cars_PG.js'; import cars_Mongo_resource from './resources/cars_mongo.js'; import cars_Ch_resource from './resources/cars_Ch.js'; - +import { ObjectId } from 'mongodb'; import auditLogsResource from "./resources/auditLogs.js" import { FICTIONAL_CAR_BRANDS, FICTIONAL_CAR_MODELS_BY_BRAND, ENGINE_TYPES, BODY_TYPES } from './custom/cars_data.js'; import passkeysResource from './resources/passkeys.js'; @@ -216,7 +216,7 @@ if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { for (let i = 0; i < 100; i++) { const engine_type = ENGINE_TYPES[Math.floor(Math.random() * ENGINE_TYPES.length)].value; await admin.resource('cars_mongo').create({ - _id: `${i}`, + _id: new ObjectId(), model: `${FICTIONAL_CAR_BRANDS[Math.floor(Math.random() * FICTIONAL_CAR_BRANDS.length)]} ${FICTIONAL_CAR_MODELS_BY_BRAND[FICTIONAL_CAR_BRANDS[Math.floor(Math.random() * FICTIONAL_CAR_BRANDS.length)]][Math.floor(Math.random() * 4)]}`, price: Decimal(Math.random() * 10000).toFixed(2), engine_type: engine_type, diff --git a/dev-demo/resources/carsResourseTemplate.ts b/dev-demo/resources/carsResourseTemplate.ts index f5df103f..c519a8a9 100644 --- a/dev-demo/resources/carsResourseTemplate.ts +++ b/dev-demo/resources/carsResourseTemplate.ts @@ -59,7 +59,7 @@ export default function carsResourseTemplate(resourceId: string, dataSource: str inputSuffix: 'USD', allowMinMaxQuery: true, editingNote: 'Price is in USD', - type: AdminForthDataTypes.FLOAT, + type: AdminForthDataTypes.DECIMAL, required: true, }, {