diff --git a/spec/model/compatibility-check/check-flex-search-on-field.spec.ts b/spec/model/compatibility-check/check-flex-search-on-field.spec.ts new file mode 100644 index 00000000..96a356ac --- /dev/null +++ b/spec/model/compatibility-check/check-flex-search-on-field.spec.ts @@ -0,0 +1,275 @@ +import gql from 'graphql-tag'; +import { + expectSingleCompatibilityIssue, + expectToBeValid, +} from '../implementation/validation-utils'; +import { runCheck } from './utils'; + +describe('checkModel', () => { + describe('@flexSearch', () => { + it('rejects if @flexSearch is missing', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearch + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Field "field" should enable @flexSearch (required by module "module1").', + ); + }); + + it('accepts @flexSearch is present', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearch + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearch + } + `, + ); + expectToBeValid(result); + }); + + it('accepts @flexSearch is present even though it is not required', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearch + } + `, + ); + expectToBeValid(result); + }); + + it('rejects if @flexSearch(includeInSearch: true) is missing', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearch(includeInSearch: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearch + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Field "field" should enable @flexSearch(includeInSearch: true) (required by module "module1").', + ); + }); + + it('rejects if @flexSearch(includeInSearch) should be true but is false', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearch(includeInSearch: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearch(includeInSearch: false) + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Field "field" should enable @flexSearch(includeInSearch: true) (required by module "module1").', + ); + }); + + it('accepts @flexSearch(includeInSearch: true)', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearch(includeInSearch: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearch(includeInSearch: true) + } + `, + ); + expectToBeValid(result); + }); + + it('accepts @flexSearch(includeInSearch: true) even if not required', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearch(includeInSearch: false) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearch(includeInSearch: true) + } + `, + ); + expectToBeValid(result); + }); + }); + + describe('@flexSearchFulltext', () => { + it('rejects if @flexSearchFulltext is missing', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearchFulltext + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Field "field" should enable @flexSearchFulltext (required by module "module1").', + ); + }); + + it('rejects if @flexSearchFulltext is missing even if @flexSearch is present', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearchFulltext + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearch + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Field "field" should enable @flexSearchFulltext (required by module "module1").', + ); + }); + + it('accepts @flexSearchFulltext is present', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearchFulltext + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearchFulltext + } + `, + ); + expectToBeValid(result); + }); + + it('accepts @flexSearchFulltext is present even though it is not required', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearchFulltext + } + `, + ); + expectToBeValid(result); + }); + + it('rejects if @flexSearchFulltext(includeInSearch: true) is missing', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearchFulltext(includeInSearch: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearchFulltext + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Field "field" should enable @flexSearch(includeInSearch: true) (required by module "module1").', + ); + }); + + it('rejects if @flexSearchFulltext(includeInSearch) should be true but is false', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearchFulltext(includeInSearch: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearchFulltext(includeInSearch: false) + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Field "field" should enable @flexSearch(includeInSearch: true) (required by module "module1").', + ); + }); + + it('accepts @flexSearchFulltext(includeInSearch: true)', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) @flexSearchFulltext(includeInSearch: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearchFulltext(includeInSearch: true) + } + `, + ); + expectToBeValid(result); + }); + + it('accepts @flexSearchFulltext(includeInSearch: true) even if not required', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String + @modules(all: true) + @flexSearchFulltext(includeInSearch: false) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String @flexSearchFulltext(includeInSearch: true) + } + `, + ); + expectToBeValid(result); + }); + }); +}); diff --git a/spec/model/compatibility-check/check-flex-search-on-type.spec.ts b/spec/model/compatibility-check/check-flex-search-on-type.spec.ts new file mode 100644 index 00000000..1596fe2d --- /dev/null +++ b/spec/model/compatibility-check/check-flex-search-on-type.spec.ts @@ -0,0 +1,139 @@ +import gql from 'graphql-tag'; +import { + expectSingleCompatibilityIssue, + expectToBeValid, +} from '../implementation/validation-utils'; +import { runCheck } from './utils'; + +describe('checkModel', () => { + describe('@rootEntity(flexSearch...)', () => { + it('rejects if flexSearch is missing', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) + } + `, + gql` + type Test @rootEntity { + field: String + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Type "Test" needs to be enable flexSearch (required by module "module1").', + ); + }); + + it('rejects if flexSearch is set to false', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) + } + `, + gql` + type Test @rootEntity(flexSearch: false) { + field: String + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Type "Test" needs to be enable flexSearch (required by module "module1").', + ); + }); + + it('accepts if flexSearch is set to true even if not needed', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: false) @modules(in: "module1") { + field: String @modules(all: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String + } + `, + ); + expectToBeValid(result); + }); + + it('rejects if flexSearchOrder is missing', () => { + const result = runCheck( + gql` + type Test + @rootEntity( + flexSearch: true + flexSearchOrder: [{ field: "field", direction: ASC }] + ) + @modules(in: "module1") { + field: String @modules(all: true) + } + `, + gql` + type Test @rootEntity(flexSearch: true) { + field: String + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Type "Test" should specify flexSearchOrder: [{field: "field", direction: ASC}] (required by module "module1").', + ); + }); + + it('rejects if flexSearchOrder should be omitted', () => { + const result = runCheck( + gql` + type Test @rootEntity(flexSearch: true) @modules(in: "module1") { + field: String @modules(all: true) + } + `, + gql` + type Test + @rootEntity( + flexSearch: true + flexSearchOrder: [{ field: "field", direction: ASC }] + ) { + field: String + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Type "Test" should not specify a custom flexSearchOrder (required by module "module1").', + ); + }); + + it('rejects if flexSearchOrder is wrong', () => { + const result = runCheck( + gql` + type Test + @rootEntity( + flexSearch: true + flexSearchOrder: [{ field: "field", direction: DESC }] + ) + @modules(in: "module1") { + field: String @modules(all: true) + } + `, + gql` + type Test + @rootEntity( + flexSearch: true + flexSearchOrder: [{ field: "field", direction: ASC }] + ) { + field: String + } + `, + ); + expectSingleCompatibilityIssue( + result, + 'Type "Test" should specify flexSearchOrder: [{field: "field", direction: DESC}] (required by module "module1").', + ); + }); + }); +}); diff --git a/src/model/compatibility-check/check-field.ts b/src/model/compatibility-check/check-field.ts index 7e1b10ce..de6fce54 100644 --- a/src/model/compatibility-check/check-field.ts +++ b/src/model/compatibility-check/check-field.ts @@ -8,6 +8,7 @@ import { checkKeyField } from './check-key-field'; import { checkReference } from './check-reference'; import { checkRelation } from './check-relation'; import { checkRootAndParentDirectives } from './check-root-and-parent-directives'; +import { checkFlexSearchOnField } from './check-flex-search-on-field'; export function checkField(fieldToCheck: Field, baselineField: Field, context: ValidationContext) { checkFieldType(fieldToCheck, baselineField, context); @@ -18,4 +19,5 @@ export function checkField(fieldToCheck: Field, baselineField: Field, context: V checkDefaultValue(fieldToCheck, baselineField, context); checkCalcMutations(fieldToCheck, baselineField, context); checkRootAndParentDirectives(fieldToCheck, baselineField, context); + checkFlexSearchOnField(fieldToCheck, baselineField, context); } diff --git a/src/model/compatibility-check/check-flex-search-on-field.ts b/src/model/compatibility-check/check-flex-search-on-field.ts new file mode 100644 index 00000000..aeb0cc25 --- /dev/null +++ b/src/model/compatibility-check/check-flex-search-on-field.ts @@ -0,0 +1,93 @@ +import { Field, RootEntityType } from '../implementation'; +import { ValidationContext, ValidationMessage } from '../validation'; +import { getRequiredBySuffix } from './describe-module-specification'; +import { ArgumentNode, Kind, ListValueNode, ObjectValueNode, print } from 'graphql'; +import { OrderDirection } from '../implementation/order'; +import { + FLEX_SEARCH_FULLTEXT_INDEXED_DIRECTIVE, + FLEX_SEARCH_INCLUDED_IN_SEARCH_ARGUMENT, + FLEX_SEARCH_INDEXED_DIRECTIVE, + FLEX_SEARCH_ORDER_ARGUMENT, +} from '../../schema/constants'; +import { FlexSearchPrimarySortClause } from '../implementation/flex-search'; + +export function checkFlexSearchOnField( + fieldToCheck: Field, + baselineField: Field, + context: ValidationContext, +) { + checkRegularDirective(fieldToCheck, baselineField, context); + checkFulltextDirective(fieldToCheck, baselineField, context); +} + +export function checkRegularDirective( + fieldToCheck: Field, + baselineField: Field, + context: ValidationContext, +) { + if (!baselineField.isFlexSearchIndexed) { + // if the baseline does not use flexSearch, the field to check is free to do anything + return; + } + if (!fieldToCheck.isFlexSearchIndexed) { + context.addMessage( + ValidationMessage.suppressableCompatibilityIssue( + 'FLEX_SEARCH', + `Field "${ + baselineField.name + }" should enable @${FLEX_SEARCH_INDEXED_DIRECTIVE}${getRequiredBySuffix(baselineField)}.`, + fieldToCheck.astNode, + ), + ); + return; + } + + if (baselineField.isIncludedInSearch && !fieldToCheck.isIncludedInSearch) { + context.addMessage( + ValidationMessage.suppressableCompatibilityIssue( + 'FLEX_SEARCH_SEARCH', + `Field "${ + baselineField.name + }" should enable @${FLEX_SEARCH_INDEXED_DIRECTIVE}(${FLEX_SEARCH_INCLUDED_IN_SEARCH_ARGUMENT}: true)${getRequiredBySuffix(baselineField)}.`, + fieldToCheck.astNode, + { location: fieldToCheck.isFlexSearchIndexedAstNode }, + ), + ); + } +} + +export function checkFulltextDirective( + fieldToCheck: Field, + baselineField: Field, + context: ValidationContext, +) { + if (!baselineField.isFlexSearchFulltextIndexed) { + // if the baseline does not use flexSearch, the field to check is free to do anything + return; + } + if (!fieldToCheck.isFlexSearchFulltextIndexed) { + context.addMessage( + ValidationMessage.suppressableCompatibilityIssue( + 'FLEX_SEARCH', + `Field "${ + baselineField.name + }" should enable @${FLEX_SEARCH_FULLTEXT_INDEXED_DIRECTIVE}${getRequiredBySuffix(baselineField)}.`, + fieldToCheck.astNode, + ), + ); + return; + } + + if (baselineField.isFulltextIncludedInSearch && !fieldToCheck.isFulltextIncludedInSearch) { + context.addMessage( + ValidationMessage.suppressableCompatibilityIssue( + 'FLEX_SEARCH_SEARCH', + `Field "${ + baselineField.name + }" should enable @${FLEX_SEARCH_INDEXED_DIRECTIVE}(${FLEX_SEARCH_INCLUDED_IN_SEARCH_ARGUMENT}: true)${getRequiredBySuffix(baselineField)}.`, + fieldToCheck.astNode, + { location: fieldToCheck.isFlexSearchFullTextIndexedAstNode }, + ), + ); + } +} diff --git a/src/model/compatibility-check/check-flex-search-on-type.ts b/src/model/compatibility-check/check-flex-search-on-type.ts new file mode 100644 index 00000000..5c60bea3 --- /dev/null +++ b/src/model/compatibility-check/check-flex-search-on-type.ts @@ -0,0 +1,134 @@ +import { RootEntityType } from '../implementation'; +import { ValidationContext, ValidationMessage } from '../validation'; +import { getRequiredBySuffix } from './describe-module-specification'; +import { ArgumentNode, Kind, ListValueNode, ObjectValueNode, print } from 'graphql'; +import { OrderDirection } from '../implementation/order'; +import { FLEX_SEARCH_ORDER_ARGUMENT } from '../../schema/constants'; +import { FlexSearchPrimarySortClause } from '../implementation/flex-search'; + +export function checkFlexSearchOnType( + typeToCheck: RootEntityType, + baselineType: RootEntityType, + context: ValidationContext, +) { + if (baselineType.isFlexSearchIndexed && !typeToCheck.isFlexSearchIndexed) { + context.addMessage( + ValidationMessage.suppressableCompatibilityIssue( + 'FLEX_SEARCH', + `Type "${ + baselineType.name + }" needs to be enable flexSearch${getRequiredBySuffix(baselineType)}.`, + typeToCheck.astNode, + { location: typeToCheck.isFlexSearchIndexedAstNode ?? typeToCheck.nameASTNode }, + ), + ); + } + + if (!baselineType.isFlexSearchIndexed || !typeToCheck.isFlexSearchIndexed) { + // it's ok to enable flexSearch if the baseline does not + return; + } + + checkPrimarySort(typeToCheck, baselineType, context); +} + +function checkPrimarySort( + typeToCheck: RootEntityType, + baselineType: RootEntityType, + context: ValidationContext, +) { + // using flexSearchPrimarySort, and not flexSearchPrimarySortAstNode, because the astNode + // might not always be there + const expectedValueNode = flexSearchSortToGraphQl(baselineType.flexSearchPrimarySort); + const actualValueNode = flexSearchSortToGraphQl(typeToCheck.flexSearchPrimarySort); + + // check whether the effective sort configuration equals + if (print(actualValueNode) === print(expectedValueNode)) { + return; + } + + // we now know that something is off. Try to figure out how the project to check should author it + + // If the baseline type has an astNode for the directive, but not for the primary sort, it's + // likely that the baseline type did not specify the argument + if (baselineType.kindAstNode && !baselineType.flexSearchPrimarySortAstNode) { + context.addMessage( + ValidationMessage.suppressableCompatibilityIssue( + 'FLEX_SEARCH_ORDER', + `Type "${ + baselineType.name + }" should not specify a custom ${FLEX_SEARCH_ORDER_ARGUMENT}${getRequiredBySuffix(baselineType)}.`, + typeToCheck.astNode, + { location: typeToCheck.flexSearchPrimarySortAstNode ?? typeToCheck.nameASTNode }, + ), + ); + return; + } + + // Use the authored baselineType.flexSearchPrimarySortAstNode if present because the constructed + // expectedValueNode can include additional fields that are added to make it unique + // (but fall back in case the ast node is not present) + const expectedArgumentNode: ArgumentNode = baselineType.flexSearchPrimarySortAstNode ?? { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: FLEX_SEARCH_ORDER_ARGUMENT, + }, + value: expectedValueNode, + }; + + context.addMessage( + ValidationMessage.suppressableCompatibilityIssue( + 'FLEX_SEARCH_ORDER', + `Type "${ + baselineType.name + }" should specify ${print(expectedArgumentNode)}${getRequiredBySuffix(baselineType)}.`, + typeToCheck.astNode, + { + // if the user specified the argument, report it there (suggesting to change it) + // otherwise, report it on the @rootEntity (suggesting to add the argument) + location: + typeToCheck.flexSearchPrimarySortAstNode ?? + typeToCheck.kindAstNode ?? + typeToCheck.nameASTNode, + }, + ), + ); +} + +function flexSearchSortToGraphQl( + clauses: ReadonlyArray, +): ListValueNode { + return { + kind: Kind.LIST, + values: clauses.map( + (clause): ObjectValueNode => ({ + kind: Kind.OBJECT, + fields: [ + { + kind: Kind.OBJECT_FIELD, + name: { + kind: Kind.NAME, + value: 'fields', + }, + value: { + kind: Kind.STRING, + value: clause.field.path, + }, + }, + { + kind: Kind.OBJECT_FIELD, + name: { + kind: Kind.NAME, + value: 'direction', + }, + value: { + kind: Kind.ENUM, + value: clause.direction === OrderDirection.DESCENDING ? 'DESC' : 'ASC', + }, + }, + ], + }), + ), + }; +} diff --git a/src/model/compatibility-check/check-root-entity-type.ts b/src/model/compatibility-check/check-root-entity-type.ts index d0b5286d..29115e51 100644 --- a/src/model/compatibility-check/check-root-entity-type.ts +++ b/src/model/compatibility-check/check-root-entity-type.ts @@ -4,6 +4,7 @@ import { checkField } from './check-field'; import { getRequiredBySuffix } from './describe-module-specification'; import { checkBusinessObject } from './check-business-object'; import { checkTtl } from './check-ttl'; +import { checkFlexSearchOnType } from './check-flex-search-on-type'; export function checkRootEntityType( typeToCheck: RootEntityType, @@ -12,4 +13,5 @@ export function checkRootEntityType( ) { checkBusinessObject(typeToCheck, baselineType, context); checkTtl(typeToCheck, baselineType, context); + checkFlexSearchOnType(typeToCheck, baselineType, context); } diff --git a/src/model/config/indices.ts b/src/model/config/indices.ts index 9e13bb3e..234b4bdd 100644 --- a/src/model/config/indices.ts +++ b/src/model/config/indices.ts @@ -1,4 +1,5 @@ import { + ArgumentNode, DirectiveNode, EnumValueNode, IntValueNode, @@ -40,8 +41,9 @@ export interface FlexSearchPrimarySortClauseConfig { export interface FlexSearchIndexConfig { readonly isIndexed: boolean; - readonly directiveASTNode?: DirectiveNode; + readonly isIndexedAstNode?: ArgumentNode; readonly primarySort: ReadonlyArray; + readonly primarySortAstNode?: ArgumentNode; /** * Bundled non-functional, optional parameters for flexsearch performance optimizations diff --git a/src/model/config/type.ts b/src/model/config/type.ts index fde5400b..b415b33f 100644 --- a/src/model/config/type.ts +++ b/src/model/config/type.ts @@ -1,5 +1,6 @@ import { ASTNode, + DirectiveNode, EnumTypeDefinitionNode, EnumValueDefinitionNode, GraphQLScalarType, @@ -36,6 +37,7 @@ export interface TypeConfigBase { export interface ObjectTypeConfigBase extends TypeConfigBase { readonly fields: ReadonlyArray; readonly astNode?: ObjectTypeDefinitionNode; + readonly kindAstNode?: DirectiveNode; } export interface RootEntityTypeConfig extends ObjectTypeConfigBase { diff --git a/src/model/create-model.ts b/src/model/create-model.ts index 983330a3..2aba24a3 100644 --- a/src/model/create-model.ts +++ b/src/model/create-model.ts @@ -102,6 +102,7 @@ import { LocalizationConfig, NamespacedPermissionProfileConfigMap, ObjectTypeConfig, + ObjectTypeConfigBase, PermissionProfileConfigMap, PermissionsConfig, RelationDeleteAction, @@ -236,14 +237,15 @@ function createObjectTypeInput( context: ValidationContext, options: ModelOptions, ): ObjectTypeConfig { - const entityType = getKindOfObjectTypeNode(definition, context); + const kindDirective = getObjectTypeKindDirective(definition, context); const common = { ...commonConfig, astNode: definition, + kindAstNode: kindDirective, fields: (definition.fields || []).map((field) => createFieldInput(field, context, options)), flexSearchLanguage: getDefaultLanguage(definition, context), - }; + } as const satisfies Partial; const businessObjectDirective = findDirectiveWithName(definition, BUSINESS_OBJECT_DIRECTIVE); if (businessObjectDirective && !findDirectiveWithName(definition, ROOT_ENTITY_DIRECTIVE)) { @@ -255,7 +257,7 @@ function createObjectTypeInput( ); } - switch (entityType) { + switch (kindDirective?.name.value) { case CHILD_ENTITY_DIRECTIVE: return { kind: TypeKind.CHILD_ENTITY, @@ -273,7 +275,7 @@ function createObjectTypeInput( }; default: // interpret unknown kinds as root entity because they are least likely to cause unnecessary errors - // (errors are already reported in getKindOfObjectTypeNode) + // (errors are already reported in getObjectTypeKindDirective) return { ...common, @@ -346,19 +348,10 @@ function getDefaultValue(fieldNode: FieldDefinitionNode, context: ValidationCont } function getFlexSearchOrder( - rootEntityDirective: DirectiveNode, + argumentNode: ArgumentNode, objectNode: ObjectTypeDefinitionNode, context: ValidationContext, ): ReadonlyArray { - const argumentNode: ArgumentNode | undefined = getNodeByName( - rootEntityDirective.arguments, - FLEX_SEARCH_ORDER_ARGUMENT, - ); - - if (!argumentNode) { - return []; - } - if (argumentNode.value.kind === Kind.LIST) { return argumentNode.value.values.map((v) => createFlexSearchPrimarySortClause(v, objectNode, context), @@ -478,24 +471,26 @@ function createFlexSearchDefinitionInputs( objectNode: ObjectTypeDefinitionNode, context: ValidationContext, ): FlexSearchIndexConfig { - let isIndexed = false; const directive = findDirectiveWithName(objectNode, ROOT_ENTITY_DIRECTIVE); - if (directive) { - const argumentIndexed: ArgumentNode | undefined = getNodeByName( - directive.arguments, - FLEX_SEARCH_INDEXED_ARGUMENT, - ); - if (argumentIndexed) { - if (argumentIndexed.value.kind === 'BooleanValue') { - isIndexed = argumentIndexed.value.value; - } - } - } + + const isIndexedAstNode = directive + ? getNodeByName(directive.arguments, FLEX_SEARCH_INDEXED_ARGUMENT) + : undefined; + const isIndexed = + isIndexedAstNode?.value.kind === 'BooleanValue' ? isIndexedAstNode.value.value : false; + + const primarySortAstNode: ArgumentNode | undefined = directive + ? getNodeByName(directive.arguments, FLEX_SEARCH_ORDER_ARGUMENT) + : undefined; + const primarySort = primarySortAstNode + ? getFlexSearchOrder(primarySortAstNode, objectNode, context) + : []; return { isIndexed, - directiveASTNode: directive, - primarySort: directive ? getFlexSearchOrder(directive, objectNode, context) : [], + isIndexedAstNode, + primarySort, + primarySortAstNode, performanceParams: directive ? getFlexSearchPerformanceParams(directive) : undefined, }; } @@ -866,10 +861,10 @@ function mapIndexDefinition(index: ObjectValueNode): IndexDefinitionConfig { }; } -function getKindOfObjectTypeNode( +function getObjectTypeKindDirective( definition: ObjectTypeDefinitionNode, context?: ValidationContext, -): string | undefined { +): DirectiveNode | undefined { const kindDirectives = (definition.directives || []).filter((dir) => OBJECT_TYPE_KIND_DIRECTIVES.includes(dir.name.value), ); @@ -896,7 +891,7 @@ function getKindOfObjectTypeNode( return undefined; } - return kindDirectives[0].name.value; + return kindDirectives[0]; } function getNamespacePath( diff --git a/src/model/implementation/field.ts b/src/model/implementation/field.ts index 8b87d03d..7b70154f 100644 --- a/src/model/implementation/field.ts +++ b/src/model/implementation/field.ts @@ -109,6 +109,8 @@ export class Field implements ModelComponent { readonly calcMutationsAstNode: DirectiveNode | undefined; readonly rootDirectiveAstNode: DirectiveNode | undefined; readonly parentDirectiveAstNode: DirectiveNode | undefined; + readonly isFlexSearchIndexedAstNode: DirectiveNode | undefined; + readonly isFlexSearchFullTextIndexedAstNode: DirectiveNode | undefined; constructor( private readonly input: SystemFieldConfig, @@ -157,6 +159,8 @@ export class Field implements ModelComponent { this.calcMutationsAstNode = input.calcMutationAstNode; this.rootDirectiveAstNode = input.rootDirectiveNode; this.parentDirectiveAstNode = input.parentDirectiveNode; + this.isFlexSearchIndexedAstNode = input.isFlexSearchIndexedASTNode; + this.isFlexSearchFullTextIndexedAstNode = input.isFlexSearchFulltextIndexedASTNode; } /** diff --git a/src/model/implementation/object-type-base.ts b/src/model/implementation/object-type-base.ts index 5bdbe4e0..d05cf55b 100644 --- a/src/model/implementation/object-type-base.ts +++ b/src/model/implementation/object-type-base.ts @@ -8,6 +8,7 @@ import { Model } from './model'; import { EffectiveModuleSpecification } from './modules/effective-module-specification'; import { ObjectType } from './type'; import { TypeBase } from './type-base'; +import { DirectiveNode } from 'graphql/index'; export abstract class ObjectTypeBase extends TypeBase { readonly fields: ReadonlyArray; @@ -15,6 +16,7 @@ export abstract class ObjectTypeBase extends TypeBase { readonly systemFieldOverrides: ReadonlyMap; readonly systemFields: ReadonlyMap; readonly systemFieldConfigs: ReadonlyMap; + readonly kindAstNode?: DirectiveNode; protected constructor( input: ObjectTypeConfig, @@ -66,6 +68,8 @@ export abstract class ObjectTypeBase extends TypeBase { this.fields = [...systemFields, ...customFields]; this.fieldMap = new Map(this.fields.map((field): [string, Field] => [field.name, field])); + + this.kindAstNode = input.kindAstNode; } validate(context: ValidationContext) { diff --git a/src/model/implementation/root-entity-type.ts b/src/model/implementation/root-entity-type.ts index 75aa6130..a9005184 100644 --- a/src/model/implementation/root-entity-type.ts +++ b/src/model/implementation/root-entity-type.ts @@ -1,4 +1,4 @@ -import { GraphQLID, GraphQLString } from 'graphql'; +import { ArgumentNode, GraphQLID, GraphQLString } from 'graphql'; import memorize from 'memorize-decorator'; import { ACCESS_FIELD_DIRECTIVE, @@ -50,7 +50,9 @@ export class RootEntityType extends ObjectTypeBase { readonly isBusinessObject: boolean; readonly isFlexSearchIndexed: boolean; + readonly isFlexSearchIndexedAstNode: ArgumentNode | undefined; readonly flexSearchPrimarySort: ReadonlyArray; + readonly flexSearchPrimarySortAstNode: ArgumentNode | undefined; readonly flexSearchPerformanceParams: FlexSearchPerformanceParams; constructor( @@ -64,11 +66,13 @@ export class RootEntityType extends ObjectTypeBase { ? new RolesSpecifier(input.permissions.roles, this) : undefined; this.isBusinessObject = input.isBusinessObject || false; + this.isFlexSearchIndexedAstNode = input.flexSearchIndexConfig?.isIndexedAstNode; if (input.flexSearchIndexConfig && input.flexSearchIndexConfig.isIndexed) { this.isFlexSearchIndexed = true; this.flexSearchPrimarySort = this.completeFlexSearchPrimarySort( input.flexSearchIndexConfig.primarySort, ); + this.flexSearchPrimarySortAstNode = input.flexSearchIndexConfig.primarySortAstNode; this.flexSearchPerformanceParams = input.flexSearchIndexConfig.performanceParams ?? {}; } else { this.isFlexSearchIndexed = false; diff --git a/src/model/validation/suppress/message-codes.ts b/src/model/validation/suppress/message-codes.ts index 5b877cb2..c5f97303 100644 --- a/src/model/validation/suppress/message-codes.ts +++ b/src/model/validation/suppress/message-codes.ts @@ -56,6 +56,13 @@ export const COMPATIBILITY_ISSUE_CODES = { BUSINESS_OBJECT: 'Missing or superfluous @businessObject', TYPE_KIND: 'A type declaration is of the wrong kind (e.g. root entity, value object or enum)', TTL: 'Missing or superfluous time-to-live configuration', + FLEX_SEARCH: 'Missing, superfluous or diverging flexSearch configuration on a type or field', + + // this is separate because it's more likely to be intentionally diverging + FLEX_SEARCH_ORDER: 'Missing, superfluous or diverging flexSearchOrder configuration', + + // this is separate because it's more likely to be intentionally diverging + FLEX_SEARCH_SEARCH: 'Missing includeInSearch in @flexSearch or @flexSearchFullText', } as const satisfies MessageCodes; export type CompatibilityIssueCode = keyof typeof COMPATIBILITY_ISSUE_CODES;