Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@
* @returns {string} - The base property name
* @private
*/
// The entityReference sub-fields a custom-property field can name.
const DISPLAY_NAME_SUFFIX = '.displayName';
const FQN_SUFFIX = '.fullyQualifiedName';

function getBasePropertyName(propertyName) {
// Handle table-cp pattern: propertyName.rows.columnName.keyword -> propertyName.rows.columnName
// Backend stores separate entries for each column with names like "propertyName.rows.columnName"
Expand All @@ -291,11 +295,11 @@
// Known nested field suffixes for complex custom property types
const nestedSuffixes = [
'.displayName.keyword',
'.displayName',
DISPLAY_NAME_SUFFIX,
'.name.keyword',
'.name',
'.fullyQualifiedName.keyword',
'.fullyQualifiedName',
FQN_SUFFIX,
'.start',
'.end',
'.keyword',
Expand All @@ -313,6 +317,32 @@
return baseName;
}

/**
* The nested sub-field holding the part of an entityReference a field names.
*
* `SearchIndexUtils.populateEntityRefFields` splits a reference across the
* nested doc: `name` into refName, `fullyQualifiedName` into refFqn and
* `displayName` into stringValue. Reading refName for all three matched a
* displayName against a name and returned nothing.
*
* @param {string} propertyName - The full property name, `.keyword` and all
* @returns {string} - The customPropertiesTyped sub-field to query
* @private
*/
function getEntityRefNestedField(propertyName) {
const path = String(propertyName ?? '').replace(/\.keyword$/, '');

if (path.endsWith(DISPLAY_NAME_SUFFIX)) {
return 'stringValue';
}

if (path.endsWith(FQN_SUFFIX)) {
return 'refFqn';
}

return 'refName';
}
Comment thread
anuj-kumary marked this conversation as resolved.

/**
* Maps an OpenMetadata custom property type (from the field config) to the
* fieldType / nestedField the ES query builder uses. This is unambiguous because
Expand All @@ -331,7 +361,10 @@
switch (omPropertyType) {
case 'entityReference':
case 'array<entityReference>':
return { fieldType: 'entityReference', nestedField: 'refName' };
return {
fieldType: 'entityReference',
nestedField: getEntityRefNestedField(propertyName),
};
case 'hyperlink-cp':
return { fieldType: 'hyperlink', nestedField: 'stringValue' };
case 'table-cp':
Expand Down Expand Up @@ -371,11 +404,14 @@
// are not misclassified. For full disambiguation when the property name itself
// is `owner.name`, callers should pass the type via getFieldTypeInfoFromOmType.
if (
propertyName.endsWith('.displayName') ||
propertyName.endsWith(DISPLAY_NAME_SUFFIX) ||
propertyName.endsWith('.name') ||
propertyName.endsWith('.fullyQualifiedName')
propertyName.endsWith(FQN_SUFFIX)
) {
return { fieldType: 'entityReference', nestedField: 'refName' };
return {
fieldType: 'entityReference',
nestedField: getEntityRefNestedField(propertyName),
};
}

// Hyperlink fields: propertyName.url.keyword or propertyName.displayText.keyword
Expand Down Expand Up @@ -437,22 +473,31 @@
return null;
}

/**
* Checks if the operator is a range operator (requires numeric field).
*
* @param {string} operator - The query operator
* @returns {boolean} - True if range operator
* @private
*/
const RANGE_OPERATOR_BOUNDS = {
less: 'lt',
less_or_equal: 'lte',
greater: 'gt',
greater_or_equal: 'gte',
};

function isRangeOperator(operator) {
return [
'between',
'not_between',
'less',
'less_or_equal',
'greater',
'greater_or_equal',
].includes(operator);
return (
operator === 'between' ||
operator === 'not_between' ||
operator in RANGE_OPERATOR_BOUNDS
);
}

function buildRangeClause(value, operator) {
if (operator === 'between' || operator === 'not_between') {
return Array.isArray(value) && value.length >= 2
? { gte: value[0], lte: value[1] }
: {};
}

return {
[RANGE_OPERATOR_BOUNDS[operator]]: Array.isArray(value) ? value[0] : value,
};
}

/**
Expand All @@ -465,49 +510,36 @@
* @returns {object} - The nested ES query
* @private
*/
// eslint-disable-next-line sonarjs/cyclomatic-complexity -- predates the budget
function buildNestedTypedQuery(propertyName, nestedField, value, operator) {
const mustClauses = [
{ term: { 'customPropertiesTyped.name': propertyName } },
];

// Build the value query based on operator
if (isRangeOperator(operator)) {
const rangeQuery = {};
if (
(operator === 'between' || operator === 'not_between') &&
Array.isArray(value) &&
value.length >= 2
) {
rangeQuery.gte = value[0];
rangeQuery.lte = value[1];
} else if (operator === 'less') {
rangeQuery.lt = Array.isArray(value) ? value[0] : value;
} else if (operator === 'less_or_equal') {
rangeQuery.lte = Array.isArray(value) ? value[0] : value;
} else if (operator === 'greater') {
rangeQuery.gt = Array.isArray(value) ? value[0] : value;
} else if (operator === 'greater_or_equal') {
rangeQuery.gte = Array.isArray(value) ? value[0] : value;
}
mustClauses.push({
range: { [`customPropertiesTyped.${nestedField}`]: rangeQuery },
});
} else {
// Exact match
const termValue = Array.isArray(value) ? value[0] : value;
mustClauses.push({
term: { [`customPropertiesTyped.${nestedField}`]: termValue },
});
}
function buildNestedTypedQuery(
propertyName,
nestedField,
value,
operator,
caseInsensitive = false
) {
const fieldPath = `customPropertiesTyped.${nestedField}`;
const termValue = Array.isArray(value) ? value[0] : value;

const valueClause = isRangeOperator(operator)
? { range: { [fieldPath]: buildRangeClause(value, operator) } }
: {
term: {
[fieldPath]: caseInsensitive
? { value: termValue, case_insensitive: true }
: termValue,
},
};

return {
nested: {
path: 'customPropertiesTyped',
ignore_unmapped: true,
query: {
bool: {
must: mustClauses,
must: [
{ term: { 'customPropertiesTyped.name': propertyName } },
valueClause,
],
},
},
},
Expand All @@ -533,7 +565,7 @@
* @private
*/
// eslint-disable-next-line sonarjs/cyclomatic-complexity -- predates the budget
function buildExtensionQuery(

Check warning on line 568 in openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderElasticsearchFormatUtils.js

View workflow job for this annotation

GitHub Actions / checkstyle

Refactor this function to reduce its Cognitive Complexity from 42 to the 15 allowed
propertyName,
entityType,
value,
Expand Down Expand Up @@ -647,12 +679,12 @@
operator
);
} else if (fieldType === 'entityReference') {
// EntityReference: use refName for exact match queries
mainQuery = buildNestedTypedQuery(
basePropertyName,
'refName',
nestedField ?? 'refName',
value,
operator
operator,
true
);
} else if (
(fieldType === 'hyperlink' || fieldType === 'table') &&
Expand Down Expand Up @@ -781,7 +813,7 @@
* @private
*/
// eslint-disable-next-line sonarjs/cyclomatic-complexity -- predates the budget
function buildEsRule(fieldName, value, operator, config, valueSrc) {

Check warning on line 816 in openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderElasticsearchFormatUtils.js

View workflow job for this annotation

GitHub Actions / checkstyle

Refactor this function to reduce its Cognitive Complexity from 34 to the 15 allowed
if (!fieldName || !operator || value === undefined) {
return undefined;
} // rule is not fully entered
Expand Down Expand Up @@ -1014,7 +1046,7 @@
}

// eslint-disable-next-line sonarjs/cyclomatic-complexity -- predates the budget
export function elasticSearchFormat(tree, config, syntax = ES_6_SYNTAX) {

Check warning on line 1049 in openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderElasticsearchFormatUtils.js

View workflow job for this annotation

GitHub Actions / checkstyle

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed
try {
const extendedConfig = extendConfigUtils.ConfigUtils.extendConfig(
config,
Expand Down Expand Up @@ -1152,7 +1184,7 @@
}

// eslint-disable-next-line sonarjs/cyclomatic-complexity -- predates the budget
export function elasticSearchFormatForJSONLogic(

Check warning on line 1187 in openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderElasticsearchFormatUtils.js

View workflow job for this annotation

GitHub Actions / checkstyle

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed
tree,
config,
syntax = ES_6_SYNTAX
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,3 +490,85 @@ describe('elasticSearchFormat – custom properties without an entity-type segme
expect(json).toContain(SCOPED_TO_TABLE);
});
});

describe('elasticSearchFormat – entityReference custom properties', () => {
// Synthetic: the builder only ever keys a reference property by `.displayName.keyword`
// (AdvancedSearchClassBase.resolveCustomPropertySubfieldsKey), so these three keys never coexist
// in a real config. They sit side by side here only to drive all three suffixes through one
// config — reading them as siblings would suggest a reload ambiguity that cannot occur.
const refConfig = {
...BasicConfig,
fields: {
...BasicConfig.fields,
extension: {
subfields: {
apiCollection: {
subfields: {
'testApiCp.displayName.keyword': {
__omPropertyType: 'entityReference',
},
'testApiCp.name.keyword': {
__omPropertyType: 'entityReference',
},
'testApiCp.fullyQualifiedName.keyword': {
__omPropertyType: 'array<entityReference>',
},
},
},
},
},
},
};

const REF_FIELD = 'extension.apiCollection.testApiCp.displayName.keyword';

const queryFor = (field) =>
JSON.stringify(
elasticSearchFormat(
makeTree('select_equals', ['address'], field),
refConfig
)
);

it('should read displayName from stringValue, where the indexer puts it', () => {
const json = queryFor(REF_FIELD);

expect(json).toContain('"customPropertiesTyped.name":"testApiCp"');
expect(json).toContain(
'"customPropertiesTyped.stringValue":{"value":"address"'
);
expect(json).not.toContain('refName');
});

// The picker's options come from a terms aggregation over `displayName.keyword`, which carries a
// `lowercase_normalizer`. `customPropertiesTyped.*` has none and keeps the original case, so an
// exact keyword term could never match the lower-cased option the user actually picked.
it('should match a reference ignoring case', () => {
const json = queryFor(REF_FIELD);

expect(json).toContain('"case_insensitive":true');
});

it('should read name from refName', () => {
const json = queryFor('extension.apiCollection.testApiCp.name.keyword');

expect(json).toContain(
'"customPropertiesTyped.refName":{"value":"address"'
);
});

it('should read fullyQualifiedName from refFqn', () => {
const json = queryFor(
'extension.apiCollection.testApiCp.fullyQualifiedName.keyword'
);

expect(json).toContain('"customPropertiesTyped.refFqn":{"value":"address"');
});

it('should keep the property name free of the sub-field suffix', () => {
const json = queryFor(REF_FIELD);

expect(json).not.toContain('"customPropertiesTyped.name":"displayName"');
expect(json).not.toContain('"customPropertiesTyped.name":"keyword"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
OldJsonItem,
OldJsonTree,
} from '@react-awesome-query-builder/ui';
import { isBoolean, isUndefined } from 'lodash';
import { isBoolean, isObject, isUndefined } from 'lodash';
import { EntityReferenceFields } from '../enums/AdvancedSearch.enum';
import { EntityType } from '../enums/entity.enum';
import type {

Check warning on line 22 in openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderPureUtils.ts

View workflow job for this annotation

GitHub Actions / checkstyle

Pages are route-level composition modules. Move the shared implementation/type to a lower layer instead of importing a page from here

Check warning on line 22 in openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderPureUtils.ts

View workflow job for this annotation

GitHub Actions / checkstyle

Pure utilities must not depend on React, UI, state, hooks, pages, or REST clients. Move orchestration/rendering out or move shared types to a lower layer
EsBoolQuery,
EsExistsQuery,
EsTerm,
Expand Down Expand Up @@ -451,7 +451,17 @@
(body: unknown) => Pick<CustomPropertyClause, 'value' | 'range'>
]
> = [
['term', (body) => ({ value: body })],
// A case-insensitive term states its value as `{ value, case_insensitive }`; a plain one is the
// bare value. Unwrap so the rule reloads with the value, not the clause body.
[
'term',
(body) => ({
value:
isObject(body) && 'value' in (body as UnknownRecord)
? (body as UnknownRecord).value
: body,
}),
],
['wildcard', (body) => ({ value: (body as { value?: unknown })?.value })],
['regexp', (body) => ({ value: (body as { value?: unknown })?.value })],
['match', (body) => ({ value: (body as { query?: unknown })?.query })],
Expand Down
Loading
Loading