Skip to content

Commit

Permalink
feat(orchestrator): add column filters (janus-idp#2273)
Browse files Browse the repository at this point in the history
* add filters to openapi spec

* cleanup folders generated docs

Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>

* update generated code

* body example

* model introspection query data

Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>

* use colum filters

Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>

* update unit test for colum filters

Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>

* fix code duplication

---------

Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>
  • Loading branch information
gciavarrini authored Oct 8, 2024
1 parent 7db423f commit 544c2c7
Show file tree
Hide file tree
Showing 31 changed files with 1,711 additions and 445 deletions.
193 changes: 151 additions & 42 deletions plugins/orchestrator-backend/src/helpers/queryBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,167 @@
import {
FieldFilterOperatorEnum,
Filter,
IntrospectionField,
TypeKind,
TypeName,
} from '@janus-idp/backstage-plugin-orchestrator-common';

import { Pagination } from '../types/pagination';
import { buildGraphQlQuery } from './queryBuilder';
import { buildFilterCondition, buildGraphQlQuery } from './queryBuilder';

describe('buildGraphQlQuery', () => {
const queryBody = 'id status';
const type = 'ProcessInstances';
const offset = 0;
const limit = 10;
const order = 'asc';
const sortField = 'name';
const pagination: Pagination = {
offset,
limit,
order,
sortField,
const defaultTestParams = {
queryBody: 'id status',
type: 'ProcessInstances' as
| 'ProcessDefinitions'
| 'ProcessInstances'
| 'Jobs',
pagination: {
offset: 0,
limit: 10,
order: 'asc',
sortField: 'name',
} as Pagination | undefined,
whereClause: 'version: "1.0"',
};

const getPaginationString = (pagination: Pagination | undefined) => {
const paginationOrder = pagination?.order
? pagination.order.toUpperCase()
: 'ASC';
if (pagination) {
return `orderBy: {${pagination.sortField}: ${paginationOrder}}, pagination: {limit: ${pagination.limit}, offset: ${pagination.offset}})`;
}
return undefined;
};

const paginationString = `orderBy: {${sortField}: ${order.toUpperCase()}}, pagination: {limit: ${limit}, offset: ${offset}})`;
const whereClause = 'version: "1.0"';
type TestCase = {
name: string;
params: typeof defaultTestParams;
expectedResult: string;
};

const testCases: TestCase[] = [
{
name: 'should build a basic query without where clause and pagination',
params: {
type: defaultTestParams.type,
queryBody: defaultTestParams.queryBody,
whereClause: '',
pagination: {},
},
expectedResult: `{${defaultTestParams.type} {${defaultTestParams.queryBody} } }`,
},
{
name: 'should build a query with a where clause',
params: {
type: defaultTestParams.type,
queryBody: defaultTestParams.queryBody,
whereClause: defaultTestParams.whereClause,
pagination: {},
},
expectedResult: `{${defaultTestParams.type} (where: {${defaultTestParams.whereClause}}) {${defaultTestParams.queryBody} } }`,
},
{
name: 'should build a query with pagination',
params: {
type: defaultTestParams.type,
queryBody: defaultTestParams.queryBody,
whereClause: '',
pagination: defaultTestParams.pagination,
},
expectedResult: `{${defaultTestParams.type} (${getPaginationString(defaultTestParams.pagination)} {${defaultTestParams.queryBody} } }`,
},
{
name: 'should build a query with both where clause and pagination',
params: {
...defaultTestParams,
},
expectedResult: `{${defaultTestParams.type} (where: {${defaultTestParams.whereClause}}, ${getPaginationString(defaultTestParams.pagination)} {${defaultTestParams.queryBody} } }`,
},
];

it('should build a basic query without where clause and pagination', () => {
const result = buildGraphQlQuery({
type,
queryBody,
testCases.forEach(({ name, params, expectedResult }) => {
it(`${name}`, () => {
const result = buildGraphQlQuery(params);
expect(result).toBe(expectedResult);
});
expect(result).toBe(`{${type} {${queryBody} } }`);
});
});

it('should build a query with a where clause', () => {
const result = buildGraphQlQuery({
type,
queryBody,
whereClause,
});
expect(result).toBe(`{${type} (where: {${whereClause}}) {${queryBody} } }`);
describe('column filters', () => {
const createStringIntrospectionField = (
name: string,
): IntrospectionField => ({
name,
type: {
name: TypeName.String,
kind: TypeKind.InputObject,
ofType: null,
},
});

it('should build a query with pagination', () => {
const result = buildGraphQlQuery({
type,
queryBody,
pagination,
});
expect(result).toBe(`{${type} (${paginationString} {${queryBody} } }`);
const createFieldFilter = (
field: string,
operator: FieldFilterOperatorEnum,
value: any,
): Filter => ({
field,
operator,
value,
});

it('should build a query with both where clause and pagination', () => {
const result = buildGraphQlQuery({
type,
queryBody,
whereClause,
pagination,
type FilterTestCase = {
name: string;
introspectionFields: IntrospectionField[];
filter: Filter | undefined;
expectedResult: string;
};

const testCases: FilterTestCase[] = [
{
name: 'returns empty string when filters are null or undefined',
introspectionFields: [],
filter: undefined,
expectedResult: '',
},
{
name: 'one string field',
introspectionFields: [createStringIntrospectionField('name')],
filter: createFieldFilter(
'name',
FieldFilterOperatorEnum.Eq,
'Hello World Workflow',
),
expectedResult: 'name: {equal: "Hello World Workflow"}',
},
{
name: 'two string field with or',
introspectionFields: [
createStringIntrospectionField('name'),
createStringIntrospectionField('id'),
],
filter: {
operator: 'OR',
filters: [
createFieldFilter(
'name',
FieldFilterOperatorEnum.Eq,
'Hello World Workflow',
),
createFieldFilter('id', FieldFilterOperatorEnum.Eq, 'yamlgreet'),
],
},
expectedResult:
'or: {name: {equal: "Hello World Workflow"}, id: {equal: "yamlgreet"}}',
},
// Add remaining test cases here
];

testCases.forEach(({ name, introspectionFields, filter, expectedResult }) => {
it(`${name}`, () => {
const result = buildFilterCondition(introspectionFields, filter);
expect(result).toBe(expectedResult);
});
expect(result).toBe(
`{${type} (where: {${whereClause}}, ${paginationString} {${queryBody} } }`,
);
});
});
129 changes: 124 additions & 5 deletions plugins/orchestrator-backend/src/helpers/queryBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { FilterInfo } from '@janus-idp/backstage-plugin-orchestrator-common';
import {
FieldFilterOperatorEnum,
Filter,
IntrospectionField,
LogicalFilter,
TypeName,
} from '@janus-idp/backstage-plugin-orchestrator-common';

import { Pagination } from '../types/pagination';

Expand Down Expand Up @@ -53,8 +59,121 @@ function buildPaginationClause(pagination?: Pagination): string {
return parts.join(', ');
}

export function buildFilterCondition(filter?: FilterInfo): string {
return filter?.fieldName && filter?.operator && filter?.fieldValue
? `${filter?.fieldName}:{ ${filter?.operator}: ${filter?.fieldValue}}`
: '';
function isLogicalFilter(filter: Filter): filter is LogicalFilter {
return (filter as LogicalFilter).filters !== undefined;
}

export function buildFilterCondition(
introspection: IntrospectionField[],
filters?: Filter,
): string {
if (!filters) {
return '';
}

if (isLogicalFilter(filters)) {
if (filters.operator) {
const subClauses =
filters.filters.map(f => buildFilterCondition(introspection, f)) ?? [];
const joinedSubClauses = `${filters.operator.toLowerCase()}: {${subClauses.join(', ')}}`;
return joinedSubClauses;
}
}

if (!isOperatorSupported(filters.operator)) {
throw new Error(`Unsopported operator ${filters.operator}`);
}

let value = filters.value;

if (filters.operator === FieldFilterOperatorEnum.IsNull) {
let booleanValue = false;
if (typeof value === 'string') {
booleanValue = value.toLowerCase() === 'true';
} else if (typeof value === 'number') {
booleanValue = value === 1;
}
return `${filters.field}: {${getGraphQLOperator(filters.operator)}: ${booleanValue}}`;
}

if (Array.isArray(value)) {
value = `[${value.map(v => formatValue(filters.field, v, introspection)).join(', ')}]`;
} else {
value = formatValue(filters.field, value, introspection);
}

if (
filters.operator === FieldFilterOperatorEnum.Eq ||
filters.operator === FieldFilterOperatorEnum.Like ||
filters.operator === FieldFilterOperatorEnum.In
) {
return `${filters.field}: {${getGraphQLOperator(filters.operator)}: ${value}}`;
}

throw new Error(`Can't build filter condition`);
}

function isOperatorSupported(operator: FieldFilterOperatorEnum): boolean {
return (
operator === FieldFilterOperatorEnum.Eq ||
operator === FieldFilterOperatorEnum.Like ||
operator === FieldFilterOperatorEnum.In ||
operator === FieldFilterOperatorEnum.IsNull
);
}

function isFieldFilterSupported(fieldDef: IntrospectionField): boolean {
return fieldDef?.type.name === TypeName.String;
}

function formatValue(
fieldName: string,
fieldValue: any,
introspection: IntrospectionField[],
): string {
const fieldDef = introspection.find(f => f.name === fieldName);
if (!fieldDef) {
throw new Error(`Can't find field "${fieldName}" definition`);
}

if (!isFieldFilterSupported) {
throw new Error(`Unsupported field type ${fieldDef.type.name}`);
}

if (fieldDef.type.name === TypeName.String) {
return `"${fieldValue}"`;
}
throw new Error(
`Failed to format value for ${fieldName} ${fieldValue} with type ${fieldDef.type.name}`,
);
}

function getGraphQLOperator(operator: FieldFilterOperatorEnum): string {
switch (operator) {
case 'EQ':
return 'equal';
case 'LIKE':
return 'like';
case 'IN':
return 'in';
case 'IS_NULL':
return 'isNull';
// case 'GT':
// // return "greaterThan"
// case 'GTE':
// return "greaterThanEqual"
// case 'LT':
// return "lessThan"
// case 'LTE':
// return "lessThanEqual"
// case 'CONTAINS':
// return "contains"
// case 'CONTAINS_ALL':
// case 'CONTAINS_ANY':
// case 'BETWEEN':
// case 'FROM':
// case 'TO':
default:
throw new Error(`Operation "${operator}" not supported`);
}
}
Loading

0 comments on commit 544c2c7

Please sign in to comment.