diff --git a/common/changes/@magek/core/fix-nested-non-exposed-graphql_2026-09-28-07-19.json b/common/changes/@magek/core/fix-nested-non-exposed-graphql_2026-09-28-07-19.json new file mode 100644 index 000000000..394c20c98 --- /dev/null +++ b/common/changes/@magek/core/fix-nested-non-exposed-graphql_2026-09-28-07-19.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Omit @nonExposed fields from nested GraphQL filter and sort inputs.", + "type": "patch", + "packageName": "@magek/core" + } + ], + "packageName": "@magek/core", + "email": "omidomirzaei@gmail.com" +} \ No newline at end of file diff --git a/packages/core/src/decorators/metadata.ts b/packages/core/src/decorators/metadata.ts index 0c1e19a38..fd0ba5de9 100644 --- a/packages/core/src/decorators/metadata.ts +++ b/packages/core/src/decorators/metadata.ts @@ -1,6 +1,6 @@ -import { AnyClass, ClassMetadata } from '@magek/common' +import { AnyClass, ClassMetadata, MagekConfig } from '@magek/common' import { buildClassMetadataFromFields } from './field-metadata-reader' -import { DecoratorMetadataObject } from './decorator-types' +import { DecoratorMetadataObject, SYMBOL_METADATA } from './decorator-types' import { NON_EXPOSED_SYMBOL } from './non-exposed' /** @@ -13,6 +13,20 @@ export function getNonExposedFields(metadata?: DecoratorMetadataObject): string[ return (metadata?.[NON_EXPOSED_SYMBOL] as string[]) || [] } +/** + * Fields that must be left out of GraphQL for a class. + * Class decorators register the list under the class name. A nested class with no + * class decorator only has the names on its own decorator metadata. + */ +export function nonExposedFieldsForClass(config: MagekConfig, type: AnyClass): string[] { + const registered = config.nonExposedGraphQLMetadataKey[type.name] + if (registered) { + return registered + } + const classRecord = type as unknown as Record + return getNonExposedFields(classRecord[SYMBOL_METADATA]) +} + /** * Get class metadata from @field() decorators. * diff --git a/packages/core/src/services/graphql/query-helpers/graphql-query-filter-arguments-builder.ts b/packages/core/src/services/graphql/query-helpers/graphql-query-filter-arguments-builder.ts index 15946749a..1e9c384de 100644 --- a/packages/core/src/services/graphql/query-helpers/graphql-query-filter-arguments-builder.ts +++ b/packages/core/src/services/graphql/query-helpers/graphql-query-filter-arguments-builder.ts @@ -10,7 +10,7 @@ import { GraphQLString, ThunkObjMap, } from 'graphql' -import { getClassMetadata } from '../../../decorators/metadata' +import { getClassMetadata, nonExposedFieldsForClass } from '../../../decorators/metadata' import { PropertyMetadata, TypeMetadata } from '@magek/common' import { GraphQLJSON } from 'graphql-scalars' import { AnyClass, MagekConfig, UUID } from '@magek/common' @@ -54,11 +54,13 @@ export class GraphqlQueryFilterArgumentsBuilder { let nestedProperties: ThunkObjMap = {} const metadata = getClassMetadata(prop.typeInfo.type) if (metadata.fields.length === 0) return GraphQLJSON - const excludeProps = this.config.nonExposedGraphQLMetadataKey[prop.name] - this.typeInformer.generateGraphQLTypeForClass(prop.typeInfo.type, excludeProps, true) + const excludeProps = nonExposedFieldsForClass(this.config, prop.typeInfo.type) + // Do not pass exclusions into the shared input-type cache. Queries are built + // before mutations, and a cached AddressInput would then drop the field there too. + this.typeInformer.generateGraphQLTypeForClass(prop.typeInfo.type, [], true) - for (const prop of metadata.fields) { - const property = { [prop.name]: { type: this.generateFilterFor(prop) } } + for (const nestedProp of nonExcludedFields(metadata.fields, excludeProps)) { + const property = { [nestedProp.name]: { type: this.generateFilterFor(nestedProp) } } nestedProperties = { ...nestedProperties, ...property } } fields = () => ({ diff --git a/packages/core/src/services/graphql/query-helpers/graphql-query-sort-builder.ts b/packages/core/src/services/graphql/query-helpers/graphql-query-sort-builder.ts index 4a3770724..eb5a26184 100644 --- a/packages/core/src/services/graphql/query-helpers/graphql-query-sort-builder.ts +++ b/packages/core/src/services/graphql/query-helpers/graphql-query-sort-builder.ts @@ -1,6 +1,6 @@ import { GraphQLEnumType, GraphQLFieldConfigArgumentMap, GraphQLInputObjectType, ThunkObjMap } from 'graphql' import { PropertyMetadata } from '@magek/common' -import { getClassMetadata } from '../../../decorators/metadata' +import { getClassMetadata, nonExposedFieldsForClass } from '../../../decorators/metadata' import { buildGraphqlSimpleEnumFor, isExternalType, nonExcludedFields } from '../common' import { GraphQLInputFieldConfig } from 'graphql/type/definition' import { GraphQLTypeInformer } from '../graphql-type-informer' @@ -42,11 +42,13 @@ export class GraphqlQuerySortBuilder { let nestedProperties: ThunkObjMap = {} const metadata = getClassMetadata(prop.typeInfo.type) if (metadata.fields.length === 0) return this.orderType - const excludeProps = this.config.nonExposedGraphQLMetadataKey[prop.name] - this.typeInformer.generateGraphQLTypeForClass(prop.typeInfo.type, excludeProps, true) + const excludeProps = nonExposedFieldsForClass(this.config, prop.typeInfo.type) + // Do not pass exclusions into the shared input-type cache. Queries are built + // before mutations, and a cached AddressInput would then drop the field there too. + this.typeInformer.generateGraphQLTypeForClass(prop.typeInfo.type, [], true) - for (const prop of metadata.fields) { - const property = { [prop.name]: { type: this.generateSortFor(prop) } } + for (const nestedProp of nonExcludedFields(metadata.fields, excludeProps)) { + const property = { [nestedProp.name]: { type: this.generateSortFor(nestedProp) } } nestedProperties = { ...nestedProperties, ...property } } fields = () => ({ diff --git a/packages/core/test/services/graphql/query-helpers/graphql-query-filter-arguments-builder.test.ts b/packages/core/test/services/graphql/query-helpers/graphql-query-filter-arguments-builder.test.ts new file mode 100644 index 000000000..139505f6b --- /dev/null +++ b/packages/core/test/services/graphql/query-helpers/graphql-query-filter-arguments-builder.test.ts @@ -0,0 +1,67 @@ +import { expect } from '../../../expect' +import { GraphQLInputObjectType } from 'graphql' +import { MagekConfig, getLogger } from '@magek/common' +import { field, nonExposed } from '../../../../src/decorators' +import { GraphQLTypeInformer } from '../../../../src/services/graphql/graphql-type-informer' +import { GraphqlQueryFilterArgumentsBuilder } from '../../../../src/services/graphql/query-helpers/graphql-query-filter-arguments-builder' + +function filterFieldsFor( + parent: new (...args: unknown[]) => unknown, + nested: new (...args: unknown[]) => unknown, + config: MagekConfig +): { filterFields: ReturnType; inputType: GraphQLInputObjectType } { + const logger = getLogger(config, 'test') + const typeInformer = new GraphQLTypeInformer(logger) + const builder = new GraphqlQueryFilterArgumentsBuilder(typeInformer, {}, config) + const args = builder.generateFilterArguments(parent, []) + const filterType = args.address.type as GraphQLInputObjectType + const inputType = typeInformer.generateGraphQLTypeForClass(nested, ['internalCode'], true) as GraphQLInputObjectType + return { filterFields: filterType.getFields(), inputType } +} + +describe('GraphqlQueryFilterArgumentsBuilder nested @nonExposed fields', () => { + it('omits @nonExposed fields on a nested class that is not itself registered', () => { + class MetadataAddress { + @field((type) => String) + public readonly street!: string + + @nonExposed + @field((type) => String) + public readonly internalCode!: string + } + + class MetadataUser { + @field((type) => MetadataAddress) + public readonly address!: MetadataAddress + } + + const { filterFields, inputType } = filterFieldsFor(MetadataUser, MetadataAddress, new MagekConfig('test')) + + expect(filterFields).to.have.property('street') + expect(filterFields).to.not.have.property('internalCode') + expect(inputType.getFields()).to.have.property('internalCode') + }) + + it('omits fields listed for the nested class name in nonExposedGraphQLMetadataKey', () => { + class ConfigAddress { + @field((type) => String) + public readonly street!: string + + @field((type) => String) + public readonly internalCode!: string + } + + class ConfigUser { + @field((type) => ConfigAddress) + public readonly address!: ConfigAddress + } + + const config = new MagekConfig('test') + config.nonExposedGraphQLMetadataKey[ConfigAddress.name] = ['internalCode'] + + const { filterFields } = filterFieldsFor(ConfigUser, ConfigAddress, config) + + expect(filterFields).to.have.property('street') + expect(filterFields).to.not.have.property('internalCode') + }) +}) diff --git a/packages/core/test/services/graphql/query-helpers/graphql-query-sort-builder.test.ts b/packages/core/test/services/graphql/query-helpers/graphql-query-sort-builder.test.ts new file mode 100644 index 000000000..1860060ef --- /dev/null +++ b/packages/core/test/services/graphql/query-helpers/graphql-query-sort-builder.test.ts @@ -0,0 +1,67 @@ +import { expect } from '../../../expect' +import { GraphQLInputObjectType } from 'graphql' +import { MagekConfig, getLogger } from '@magek/common' +import { field, nonExposed } from '../../../../src/decorators' +import { GraphQLTypeInformer } from '../../../../src/services/graphql/graphql-type-informer' +import { GraphqlQuerySortBuilder } from '../../../../src/services/graphql/query-helpers/graphql-query-sort-builder' + +function sortFieldsFor( + parent: new (...args: unknown[]) => unknown, + nested: new (...args: unknown[]) => unknown, + config: MagekConfig +): { sortFields: ReturnType; inputType: GraphQLInputObjectType } { + const logger = getLogger(config, 'test') + const typeInformer = new GraphQLTypeInformer(logger) + const builder = new GraphqlQuerySortBuilder(typeInformer, config) + const args = builder.generateSortArguments(parent, []) + const sortType = args.address.type as GraphQLInputObjectType + const inputType = typeInformer.generateGraphQLTypeForClass(nested, ['internalCode'], true) as GraphQLInputObjectType + return { sortFields: sortType.getFields(), inputType } +} + +describe('GraphqlQuerySortBuilder nested @nonExposed fields', () => { + it('omits @nonExposed fields on a nested class that is not itself registered', () => { + class MetadataAddress { + @field((type) => String) + public readonly street!: string + + @nonExposed + @field((type) => String) + public readonly internalCode!: string + } + + class MetadataUser { + @field((type) => MetadataAddress) + public readonly address!: MetadataAddress + } + + const { sortFields, inputType } = sortFieldsFor(MetadataUser, MetadataAddress, new MagekConfig('test')) + + expect(sortFields).to.have.property('street') + expect(sortFields).to.not.have.property('internalCode') + expect(inputType.getFields()).to.have.property('internalCode') + }) + + it('omits fields listed for the nested class name in nonExposedGraphQLMetadataKey', () => { + class ConfigAddress { + @field((type) => String) + public readonly street!: string + + @field((type) => String) + public readonly internalCode!: string + } + + class ConfigUser { + @field((type) => ConfigAddress) + public readonly address!: ConfigAddress + } + + const config = new MagekConfig('test') + config.nonExposedGraphQLMetadataKey[ConfigAddress.name] = ['internalCode'] + + const { sortFields } = sortFieldsFor(ConfigUser, ConfigAddress, config) + + expect(sortFields).to.have.property('street') + expect(sortFields).to.not.have.property('internalCode') + }) +})