From 2e791161c1b1e4a0e1cb38629983f50c74ec7737 Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Mon, 28 Sep 2026 11:20:13 +0400 Subject: [PATCH] fix(core): omit @nonExposed fields from nested filter and sort inputs Filter and sort builders looked up non-exposed fields by the property name. Decorators store that list under the class name, and a nested class with no class decorator only keeps it on its own metadata. Those fields are now left out of the nested inputs. The shared class input type is unchanged. Fixes #702 --- ...-non-exposed-graphql_2026-09-28-07-19.json | 11 +++ packages/core/src/decorators/metadata.ts | 18 ++++- .../graphql-query-filter-arguments-builder.ts | 12 ++-- .../graphql-query-sort-builder.ts | 12 ++-- ...hql-query-filter-arguments-builder.test.ts | 67 +++++++++++++++++++ .../graphql-query-sort-builder.test.ts | 67 +++++++++++++++++++ 6 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 common/changes/@magek/core/fix-nested-non-exposed-graphql_2026-09-28-07-19.json create mode 100644 packages/core/test/services/graphql/query-helpers/graphql-query-filter-arguments-builder.test.ts create mode 100644 packages/core/test/services/graphql/query-helpers/graphql-query-sort-builder.test.ts 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') + }) +})