Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export class FilteringFieldsDs {
field: string;

@ApiProperty()
value: string;
value: unknown;
Comment on lines 122 to +130

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FilteringFieldsDs.value is now unknown, which weakens the generated Swagger schema for this response DTO. Consider using a narrower union type and @ApiProperty({ oneOf: ... }) / isArray metadata so clients can understand expected scalar vs array shapes for in filters.

Copilot uses AI. Check for mistakes.
}

export class AutocompleteFieldsDs {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/entities/table/table-datastructures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class FilteringFieldsDs {
criteria: FilterCriteriaEnum;

@ApiProperty()
value: string;
value: unknown;
Comment on lines 12 to +16

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FilteringFieldsDs.value was widened to unknown, which makes the public API/Swagger schema much less informative. If the intent is to support multi-value filters, consider narrowing this to a union (e.g., string | string[] or string | number | boolean | null | (string|number)[]) and updating the @ApiProperty metadata accordingly.

Copilot uses AI. Check for mistakes.
}

export class ForeignKeyDSInfo {
Expand Down
28 changes: 27 additions & 1 deletion backend/src/entities/table/utils/find-filtering-fields.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ export function findFilteringFieldsUtil(
value: filters[`f_${fieldname}__empty`],
});
}

if (isObjectPropertyExists(filters, `f_${fieldname}__in`)) {
const rawValue = filters[`f_${fieldname}__in`];
filteringItems.push({
field: fieldname,
criteria: FilterCriteriaEnum.in,
value: Array.isArray(rawValue)
? rawValue
: String(rawValue)
.split(',')
.map((v) => v.trim()),
});
}

if (isObjectPropertyExists(filters, `f_${fieldname}__between`)) {
const rawValue = filters[`f_${fieldname}__between`];
filteringItems.push({
field: fieldname,
criteria: FilterCriteriaEnum.between,
value: Array.isArray(rawValue)
? rawValue
: String(rawValue)
.split(',')
.map((v) => v.trim()),
});
Comment on lines +93 to +114

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__in values are split and trimmed, but empty entries are not removed and empty arrays are allowed. Downstream DAOs may generate invalid queries for an empty set (e.g., IN ()). Consider filtering out empty strings and rejecting/handling the empty-set case here so behavior is consistent across drivers.

Suggested change
filteringItems.push({
field: fieldname,
criteria: FilterCriteriaEnum.in,
value: Array.isArray(rawValue)
? rawValue
: String(rawValue)
.split(',')
.map((v) => v.trim()),
});
const inValues = (Array.isArray(rawValue)
? rawValue.map((value) => (typeof value === 'string' ? value.trim() : value))
: String(rawValue)
.split(',')
.map((value) => value.trim())
).filter((value) => value !== '');
if (inValues.length > 0) {
filteringItems.push({
field: fieldname,
criteria: FilterCriteriaEnum.in,
value: inValues,
});
}

Copilot uses AI. Check for mistakes.
}
Comment on lines +91 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate __in / __between values before storing filter criteria.

Line 91-115 accepts empty tokens and arbitrary between arity. That can propagate malformed filters and break DAO query generation (IN () / undefined upper bound).

Suggested fix
 		if (isObjectPropertyExists(filters, `f_${fieldname}__in`)) {
 			const rawValue = filters[`f_${fieldname}__in`];
+			const inValues = (Array.isArray(rawValue) ? rawValue : String(rawValue).split(','))
+				.map((v) => String(v).trim())
+				.filter((v) => v.length > 0);
+			if (inValues.length === 0) {
+				continue;
+			}
 			filteringItems.push({
 				field: fieldname,
 				criteria: FilterCriteriaEnum.in,
-				value: Array.isArray(rawValue)
-					? rawValue
-					: String(rawValue)
-							.split(',')
-							.map((v) => v.trim()),
+				value: inValues,
 			});
 		}
 
 		if (isObjectPropertyExists(filters, `f_${fieldname}__between`)) {
 			const rawValue = filters[`f_${fieldname}__between`];
+			const betweenValues = (Array.isArray(rawValue) ? rawValue : String(rawValue).split(','))
+				.map((v) => String(v).trim())
+				.filter((v) => v.length > 0);
+			if (betweenValues.length !== 2) {
+				throw new Error(`Invalid between filter for "${fieldname}". Expected exactly 2 values.`);
+			}
 			filteringItems.push({
 				field: fieldname,
 				criteria: FilterCriteriaEnum.between,
-				value: Array.isArray(rawValue)
-					? rawValue
-					: String(rawValue)
-							.split(',')
-							.map((v) => v.trim()),
+				value: betweenValues,
 			});
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isObjectPropertyExists(filters, `f_${fieldname}__in`)) {
const rawValue = filters[`f_${fieldname}__in`];
filteringItems.push({
field: fieldname,
criteria: FilterCriteriaEnum.in,
value: Array.isArray(rawValue)
? rawValue
: String(rawValue)
.split(',')
.map((v) => v.trim()),
});
}
if (isObjectPropertyExists(filters, `f_${fieldname}__between`)) {
const rawValue = filters[`f_${fieldname}__between`];
filteringItems.push({
field: fieldname,
criteria: FilterCriteriaEnum.between,
value: Array.isArray(rawValue)
? rawValue
: String(rawValue)
.split(',')
.map((v) => v.trim()),
});
}
if (isObjectPropertyExists(filters, `f_${fieldname}__in`)) {
const rawValue = filters[`f_${fieldname}__in`];
const inValues = (Array.isArray(rawValue) ? rawValue : String(rawValue).split(','))
.map((v) => String(v).trim())
.filter((v) => v.length > 0);
if (inValues.length === 0) {
continue;
}
filteringItems.push({
field: fieldname,
criteria: FilterCriteriaEnum.in,
value: inValues,
});
}
if (isObjectPropertyExists(filters, `f_${fieldname}__between`)) {
const rawValue = filters[`f_${fieldname}__between`];
const betweenValues = (Array.isArray(rawValue) ? rawValue : String(rawValue).split(','))
.map((v) => String(v).trim())
.filter((v) => v.length > 0);
if (betweenValues.length !== 2) {
throw new Error(`Invalid between filter for "${fieldname}". Expected exactly 2 values.`);
}
filteringItems.push({
field: fieldname,
criteria: FilterCriteriaEnum.between,
value: betweenValues,
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/entities/table/utils/find-filtering-fields.util.ts` around lines
91 - 115, The current handling of filters[`f_${fieldname}__in`] and
filters[`f_${fieldname}__between`] can produce empty tokens or wrong arity;
before pushing into filteringItems (for FilterCriteriaEnum.in and
FilterCriteriaEnum.between) validate and normalize rawValue: convert non-array
to String(...).split(',').map(v=>v.trim()).filter(Boolean) and for __in only
push if the resulting array.length > 0; for __between ensure the resulting
array.length === 2 and both values are non-empty (or otherwise skip/throw),
using the existing symbols filters, fieldname, filteringItems,
FilterCriteriaEnum.in and FilterCriteriaEnum.between to locate and update the
logic.

}
return filteringItems;
}
Expand All @@ -99,7 +125,7 @@ export function parseFilteringFieldsFromBodyData(
const rowNames = tableStructure.map((el) => el.column_name);
rowNames.forEach((rowName) => {
if (isObjectPropertyExists(filtersDataFromBody, rowName)) {
const filterData = filtersDataFromBody[rowName] as Record<string, string>;
const filterData = filtersDataFromBody[rowName] as Record<string, unknown>;
for (const key in filterData) {
Comment on lines +128 to 129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard body filter payload shape before iterating.

Line 128 assumes filtersDataFromBody[rowName] is an object. If it is null, array, or primitive, iteration can throw or produce invalid criteria parsing.

Suggested fix
-			const filterData = filtersDataFromBody[rowName] as Record<string, unknown>;
+			const rawFilterData = filtersDataFromBody[rowName];
+			if (!rawFilterData || typeof rawFilterData !== 'object' || Array.isArray(rawFilterData)) {
+				throw new Error(`Invalid filters payload for "${rowName}".`);
+			}
+			const filterData = rawFilterData as Record<string, unknown>;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const filterData = filtersDataFromBody[rowName] as Record<string, unknown>;
for (const key in filterData) {
const rawFilterData = filtersDataFromBody[rowName];
if (!rawFilterData || typeof rawFilterData !== 'object' || Array.isArray(rawFilterData)) {
throw new Error(`Invalid filters payload for "${rowName}".`);
}
const filterData = rawFilterData as Record<string, unknown>;
for (const key in filterData) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/entities/table/utils/find-filtering-fields.util.ts` around lines
128 - 129, Guard against non-object payloads before iterating
filtersDataFromBody[rowName]: ensure the value (assigned to filterData) is a
non-null plain object (typeof === 'object' && filterData !== null &&
!Array.isArray(filterData)) and bail/continue if not, so iteration over
filterData in the loop does not throw or produce invalid criteria; apply this
check around where filtersDataFromBody, rowName, and filterData are used (in the
function in find-filtering-fields.util.ts) and handle invalid shapes by skipping
or returning an appropriate default.

if (!validateStringWithEnum(key, FilterCriteriaEnum)) {
throw new Error(`Invalid filter criteria: "${key}".`);
Expand Down
2 changes: 2 additions & 0 deletions backend/src/enums/filter-criteria.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ export enum FilterCriteriaEnum {
icontains = 'icontains',
eq = 'eq',
empty = 'empty',
in = 'in',
between = 'between',
}
128 changes: 128 additions & 0 deletions backend/test/ava-tests/non-saas-tests/non-saas-table-mysql-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3663,3 +3663,131 @@ test.serial(`${currentTest} should test connection and return result`, async (t)
const { message } = JSON.parse(testConnectionResponse.text);
t.is(message, 'Successfully connected');
});

currentTest = 'POST /table/rows/find/:slug';

test.serial(`${currentTest} should return rows filtered by IN operator in body`, async (t) => {
const connectionToTestDB = getTestData(mockFactory).connectionToMySQL;
const firstUserToken = (await registerUserAndReturnUserInfo(app)).token;
const { testTableName } = await createTestTable(connectionToTestDB);

testTables.push(testTableName);

const createConnectionResponse = await request(app.getHttpServer())
.post('/connection')
.send(connectionToTestDB)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const createConnectionRO = JSON.parse(createConnectionResponse.text);
t.is(createConnectionResponse.status, 201);

const filters = {
id: { in: ['1', '22', '38'] },
};

const getTableRowsResponse = await request(app.getHttpServer())
.post(`/table/rows/find/${createConnectionRO.id}?tableName=${testTableName}&page=1&perPage=20`)
.send({ filters })
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');

const getTableRowsRO = JSON.parse(getTableRowsResponse.text);
t.is(getTableRowsResponse.status, 200);
t.is(getTableRowsRO.rows.length, 3);
const returnedIds = getTableRowsRO.rows.map((row) => row.id).sort((a, b) => a - b);
t.deepEqual(returnedIds, [1, 22, 38]);
});

test.serial(`${currentTest} should return rows filtered by IN operator in query params`, async (t) => {
const connectionToTestDB = getTestData(mockFactory).connectionToMySQL;
const firstUserToken = (await registerUserAndReturnUserInfo(app)).token;
const { testTableName } = await createTestTable(connectionToTestDB);

testTables.push(testTableName);

const createConnectionResponse = await request(app.getHttpServer())
.post('/connection')
.send(connectionToTestDB)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const createConnectionRO = JSON.parse(createConnectionResponse.text);
t.is(createConnectionResponse.status, 201);

const getTableRowsResponse = await request(app.getHttpServer())
.get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}&page=1&perPage=20&f_id__in=1,22,38`)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');

const getTableRowsRO = JSON.parse(getTableRowsResponse.text);
t.is(getTableRowsResponse.status, 200);
t.is(getTableRowsRO.rows.length, 3);
const returnedIds = getTableRowsRO.rows.map((row) => row.id).sort((a, b) => a - b);
t.deepEqual(returnedIds, [1, 22, 38]);
});

test.serial(`${currentTest} should return rows filtered by BETWEEN operator in body`, async (t) => {
const connectionToTestDB = getTestData(mockFactory).connectionToMySQL;
const firstUserToken = (await registerUserAndReturnUserInfo(app)).token;
const { testTableName } = await createTestTable(connectionToTestDB);

testTables.push(testTableName);

const createConnectionResponse = await request(app.getHttpServer())
.post('/connection')
.send(connectionToTestDB)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const createConnectionRO = JSON.parse(createConnectionResponse.text);
t.is(createConnectionResponse.status, 201);

const filters = {
id: { between: ['20', '25'] },
};

const getTableRowsResponse = await request(app.getHttpServer())
.post(`/table/rows/find/${createConnectionRO.id}?tableName=${testTableName}&page=1&perPage=20`)
.send({ filters })
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');

const getTableRowsRO = JSON.parse(getTableRowsResponse.text);
t.is(getTableRowsResponse.status, 200);
t.is(getTableRowsRO.rows.length, 6);
const returnedIds = getTableRowsRO.rows.map((row) => row.id).sort((a, b) => a - b);
t.deepEqual(returnedIds, [20, 21, 22, 23, 24, 25]);
});

test.serial(`${currentTest} should return rows filtered by BETWEEN operator in query params`, async (t) => {
const connectionToTestDB = getTestData(mockFactory).connectionToMySQL;
const firstUserToken = (await registerUserAndReturnUserInfo(app)).token;
const { testTableName } = await createTestTable(connectionToTestDB);

testTables.push(testTableName);

const createConnectionResponse = await request(app.getHttpServer())
.post('/connection')
.send(connectionToTestDB)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const createConnectionRO = JSON.parse(createConnectionResponse.text);
t.is(createConnectionResponse.status, 201);

const getTableRowsResponse = await request(app.getHttpServer())
.get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}&page=1&perPage=20&f_id__between=20,25`)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');

const getTableRowsRO = JSON.parse(getTableRowsResponse.text);
t.is(getTableRowsResponse.status, 200);
t.is(getTableRowsRO.rows.length, 6);
const returnedIds = getTableRowsRO.rows.map((row) => row.id).sort((a, b) => a - b);
t.deepEqual(returnedIds, [20, 21, 22, 23, 24, 25]);
});
128 changes: 128 additions & 0 deletions backend/test/ava-tests/saas-tests/table-postgres-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4550,3 +4550,131 @@ test.serial(`${currentTest} should throw exception whe csv import is disabled`,
const { message } = JSON.parse(importCsvResponse.text);
t.is(message, Messages.CSV_IMPORT_DISABLED);
});

currentTest = 'POST /table/rows/find/:slug';

test.serial(`${currentTest} should return rows filtered by IN operator in body`, async (t) => {
const connectionToTestDB = getTestData(mockFactory).connectionToPostgres;
const firstUserToken = (await registerUserAndReturnUserInfo(app)).token;
const { testTableName } = await createTestTable(connectionToTestDB);

testTables.push(testTableName);

const createConnectionResponse = await request(app.getHttpServer())
.post('/connection')
.send(connectionToTestDB)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const createConnectionRO = JSON.parse(createConnectionResponse.text);
t.is(createConnectionResponse.status, 201);

const filters = {
id: { in: ['1', '22', '38'] },
};

const getTableRowsResponse = await request(app.getHttpServer())
.post(`/table/rows/find/${createConnectionRO.id}?tableName=${testTableName}&page=1&perPage=20`)
.send({ filters })
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');

const getTableRowsRO = JSON.parse(getTableRowsResponse.text);
t.is(getTableRowsResponse.status, 200);
t.is(getTableRowsRO.rows.length, 3);
const returnedIds = getTableRowsRO.rows.map((row) => row.id).sort((a, b) => a - b);
t.deepEqual(returnedIds, [1, 22, 38]);
});

test.serial(`${currentTest} should return rows filtered by IN operator in query params`, async (t) => {
const connectionToTestDB = getTestData(mockFactory).connectionToPostgres;
const firstUserToken = (await registerUserAndReturnUserInfo(app)).token;
const { testTableName } = await createTestTable(connectionToTestDB);

testTables.push(testTableName);

const createConnectionResponse = await request(app.getHttpServer())
.post('/connection')
.send(connectionToTestDB)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const createConnectionRO = JSON.parse(createConnectionResponse.text);
t.is(createConnectionResponse.status, 201);

const getTableRowsResponse = await request(app.getHttpServer())
.get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}&page=1&perPage=20&f_id__in=1,22,38`)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');

const getTableRowsRO = JSON.parse(getTableRowsResponse.text);
t.is(getTableRowsResponse.status, 200);
t.is(getTableRowsRO.rows.length, 3);
const returnedIds = getTableRowsRO.rows.map((row) => row.id).sort((a, b) => a - b);
t.deepEqual(returnedIds, [1, 22, 38]);
});

test.serial(`${currentTest} should return rows filtered by BETWEEN operator in body`, async (t) => {
const connectionToTestDB = getTestData(mockFactory).connectionToPostgres;
const firstUserToken = (await registerUserAndReturnUserInfo(app)).token;
const { testTableName } = await createTestTable(connectionToTestDB);

testTables.push(testTableName);

const createConnectionResponse = await request(app.getHttpServer())
.post('/connection')
.send(connectionToTestDB)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const createConnectionRO = JSON.parse(createConnectionResponse.text);
t.is(createConnectionResponse.status, 201);

const filters = {
id: { between: ['20', '25'] },
};

const getTableRowsResponse = await request(app.getHttpServer())
.post(`/table/rows/find/${createConnectionRO.id}?tableName=${testTableName}&page=1&perPage=20`)
.send({ filters })
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');

const getTableRowsRO = JSON.parse(getTableRowsResponse.text);
t.is(getTableRowsResponse.status, 200);
t.is(getTableRowsRO.rows.length, 6);
const returnedIds = getTableRowsRO.rows.map((row) => row.id).sort((a, b) => a - b);
t.deepEqual(returnedIds, [20, 21, 22, 23, 24, 25]);
});

test.serial(`${currentTest} should return rows filtered by BETWEEN operator in query params`, async (t) => {
const connectionToTestDB = getTestData(mockFactory).connectionToPostgres;
const firstUserToken = (await registerUserAndReturnUserInfo(app)).token;
const { testTableName } = await createTestTable(connectionToTestDB);

testTables.push(testTableName);

const createConnectionResponse = await request(app.getHttpServer())
.post('/connection')
.send(connectionToTestDB)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
const createConnectionRO = JSON.parse(createConnectionResponse.text);
t.is(createConnectionResponse.status, 201);

const getTableRowsResponse = await request(app.getHttpServer())
.get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}&page=1&perPage=20&f_id__between=20,25`)
.set('Cookie', firstUserToken)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');

const getTableRowsRO = JSON.parse(getTableRowsResponse.text);
t.is(getTableRowsResponse.status, 200);
t.is(getTableRowsRO.rows.length, 6);
const returnedIds = getTableRowsRO.rows.map((row) => row.id).sort((a, b) => a - b);
t.deepEqual(returnedIds, [20, 21, 22, 23, 24, 25]);
});
21 changes: 11 additions & 10 deletions rocketadmin-agent/src/enums/filter-criteria.enum.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
export enum FilterCriteriaEnum {
startswith = 'startswith',
endswith = 'endswith',
gt = 'gt',
lt = 'lt',
lte = 'lte',
gte = 'gte',
contains = 'contains',
icontains = 'icontains',
eq = 'eq',
empty = 'empty',
startswith = 'startswith',
endswith = 'endswith',
gt = 'gt',
lt = 'lt',
lte = 'lte',
gte = 'gte',
contains = 'contains',
icontains = 'icontains',
eq = 'eq',
empty = 'empty',
in = 'in',
}
Loading
Loading