Skip to content

fix(schema): invalid empty GraphQL types caused by specific CSN definitions #202

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1e9bc7f
Add schema validation to schema generation
schwma Jun 13, 2025
a1c37f0
Add schema generation test for empty aspects
schwma Jun 13, 2025
cc40329
Fix schema generation for empty aspects (one and many)
schwma Jun 16, 2025
6dfb6ed
Move up early return
schwma Jun 16, 2025
577dd0c
Simpler array length check syntax
schwma Jun 16, 2025
7019c57
Omit services without entities and empty services
schwma Jun 16, 2025
eb96e6a
Add `GraphQLVoid` scalar type and add `_empty` field with type `Void`…
schwma Jun 17, 2025
9980bba
Add tests for empty service and empty entity definitions
schwma Jun 17, 2025
8cc339a
Move empty csn definition schema tests to own test project
schwma Jun 17, 2025
98844e4
Add comment explaining `_empty` field
schwma Jun 17, 2025
8065a11
Add test testing `_empty` placeholder field of root query type
schwma Jun 17, 2025
bd00c32
Add tests for invalid schemas caused by special chars in CSN defs
schwma Jun 17, 2025
086f4d3
Add changelog entries
schwma Jun 17, 2025
f7c3606
Rename `_empty` to `_`
schwma Jun 18, 2025
05fa2e0
Shorten phrasing of changelog entries
schwma Jun 18, 2025
470e970
Rename test suite
schwma Jun 18, 2025
d9ce5cb
Add additional describe block to test suite
schwma Jun 18, 2025
a6553a0
Log when empty query types are omitted during schema generation
schwma Jun 27, 2025
c06db99
Move up early return due to missing scalar fields
schwma Jul 2, 2025
a94e1e2
Also cache and retrieve empty -> `undefined` types
schwma Jul 2, 2025
17d5ce4
Remove `new` prefix from type variable names
schwma Jul 2, 2025
bfd3918
Add test with service that will become empty since it only contains a…
schwma Jul 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Schema validation after schema generation (on server startup or manual compilation) to ensure that the generated schema is valid according to the GraphQL specification
- Scalar type `Void` that represents the absence of a return value and only allows value `null`

### Changed

### Fixed

- Improved schema generation robustness where specific CDS modelling could cause empty GraphQL types to be generated (which is not allowed) for entities, aspects and services. These types and any resulting empty parent types are omitted from the generated schema and a warning is logged for them. If schema generation would result in an empty query root type, a placeholder field `_` of type `Void` is added to keep the schema valid.

### Removed

## Version 0.12.0 - 2025-05-05
Expand Down
2 changes: 1 addition & 1 deletion lib/resolvers/field.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const { isObjectType, isIntrospectionType } = require('graphql')
// The GraphQL.js defaultFieldResolver does not support returning aliased values that resolve to fields with aliases
function aliasFieldResolver(source, args, contextValue, info) {
const responseKey = info.fieldNodes[0].alias ? info.fieldNodes[0].alias.value : info.fieldName
return source[responseKey]
return source?.[responseKey]
}

const registerAliasFieldResolvers = schema => {
Expand Down
21 changes: 10 additions & 11 deletions lib/schema/args/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,23 @@ const {

module.exports = cache => {
const generateFilterForEntity = entity => {
if (!hasScalarFields(entity)) return

const filterName = gqlName(entity.name) + '_filter'

const cachedFilterInputType = cache.get(filterName)
if (cachedFilterInputType) return cachedFilterInputType
if (!hasScalarFields(entity)) return
if (cache.has(filterName)) return cache.get(filterName)

const fields = {}
const newFilterInputType = new GraphQLList(new GraphQLInputObjectType({ name: filterName, fields: () => fields }))
cache.set(filterName, newFilterInputType)
const filterInputType = new GraphQLList(new GraphQLInputObjectType({ name: filterName, fields: () => fields }))
cache.set(filterName, filterInputType)

for (const name in entity.elements) {
const element = entity.elements[name]
const type = generateFilterForElement(element)
if (type) fields[gqlName(name)] = { type }
}

return newFilterInputType
return filterInputType
}

const generateFilterForElement = (element, followAssocOrComp) => {
Expand Down Expand Up @@ -84,15 +84,14 @@ module.exports = cache => {
const _generateFilterType = (gqlType, operations) => {
const filterName = gqlType.name + '_filter'

const cachedFilterType = cache.get(filterName)
if (cachedFilterType) return cachedFilterType
if (cache.has(filterName)) return cache.get(filterName)

const ops = operations.map(op => [[op], { type: OPERATOR_LIST_SUPPORT[op] ? new GraphQLList(gqlType) : gqlType }])
const fields = Object.fromEntries(ops)
const newFilterType = new GraphQLInputObjectType({ name: filterName, fields })
cache.set(filterName, newFilterType)
const filterType = new GraphQLInputObjectType({ name: filterName, fields })
cache.set(filterName, filterType)

return newFilterType
return filterType
}

return { generateFilterForEntity, generateFilterForElement }
Expand Down
16 changes: 9 additions & 7 deletions lib/schema/args/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ module.exports = cache => {
const suffix = isUpdate ? '_U' : '_C'
const entityName = gqlName(entity.name) + suffix

const cachedEntityInputObjectType = cache.get(entityName)
if (cachedEntityInputObjectType) return cachedEntityInputObjectType
if (cache.has(entityName)) return cache.get(entityName)

const fields = {}
const newEntityInputObjectType = new GraphQLInputObjectType({ name: entityName, fields: () => fields })
cache.set(entityName, newEntityInputObjectType)
const entityInputObjectType = new GraphQLInputObjectType({ name: entityName, fields: () => fields })
cache.set(entityName, entityInputObjectType)

for (const name in entity.elements) {
const element = entity.elements[name]
Expand All @@ -22,9 +21,12 @@ module.exports = cache => {
}

// fields is empty if update input object is generated for an entity that only contains key elements
if (Object.keys(fields).length === 0) return
if (!Object.keys(fields).length) {
cache.set(entityName)
return
}

return newEntityInputObjectType
return entityInputObjectType
}

const _elementToInputObjectType = (element, isUpdate) => {
Expand All @@ -38,7 +40,7 @@ module.exports = cache => {
if (element.isAssociation || element.isComposition) {
// Input objects in deep updates overwrite previous entries with new entries and therefore always act as create input objects
const type = gqlScalarType || entityToInputObjectType(element._target, false)
return element.is2one ? type : new GraphQLList(type)
if (type) return element.is2one ? type : new GraphQLList(type)
} else if (gqlScalarType) {
return gqlScalarType
}
Expand Down
21 changes: 10 additions & 11 deletions lib/schema/args/orderBy.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,23 @@ const { cdsToGraphQLScalarType } = require('../types/scalar')

module.exports = cache => {
const generateOrderByForEntity = entity => {
if (!hasScalarFields(entity)) return

const orderByName = gqlName(entity.name) + '_orderBy'

const cachedOrderByInputType = cache.get(orderByName)
if (cachedOrderByInputType) return cachedOrderByInputType
if (!hasScalarFields(entity)) return
if (cache.has(orderByName)) return cache.get(orderByName)

const fields = {}
const newOrderByInputType = new GraphQLList(new GraphQLInputObjectType({ name: orderByName, fields: () => fields }))
cache.set(orderByName, newOrderByInputType)
const orderByInputType = new GraphQLList(new GraphQLInputObjectType({ name: orderByName, fields: () => fields }))
cache.set(orderByName, orderByInputType)

for (const name in entity.elements) {
const element = entity.elements[name]
const type = generateOrderByForElement(element)
if (type) fields[gqlName(name)] = { type }
}

return newOrderByInputType
return orderByInputType
}

const generateOrderByForElement = (element, followAssocOrComp) => {
Expand All @@ -38,16 +38,15 @@ module.exports = cache => {
const _generateSortDirectionEnum = () => {
const enumName = 'SortDirection'

const cachedSortDirectionEnum = cache.get(enumName)
if (cachedSortDirectionEnum) return cachedSortDirectionEnum
if (cache.has(enumName)) return cache.get(enumName)

const newSortDirectionEnum = new GraphQLEnumType({
const sortDirectionEnum = new GraphQLEnumType({
name: enumName,
values: { asc: { value: 'asc' }, desc: { value: 'desc' } }
})
cache.set(enumName, newSortDirectionEnum)
cache.set(enumName, sortDirectionEnum)

return newSortDirectionEnum
return sortDirectionEnum
}

return { generateOrderByForEntity, generateOrderByForElement }
Expand Down
11 changes: 10 additions & 1 deletion lib/schema/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
const queryGenerator = require('./query')
const mutationGenerator = require('./mutation')
const { GraphQLSchema } = require('graphql')
const { GraphQLSchema, validateSchema } = require('graphql')
const { createRootResolvers, registerAliasFieldResolvers } = require('../resolvers')

function generateSchema4(services) {
const resolvers = createRootResolvers(services)
const cache = new Map()

const query = queryGenerator(cache).generateQueryObjectType(services, resolvers.Query)
const mutation = mutationGenerator(cache).generateMutationObjectType(services, resolvers.Mutation)
const schema = new GraphQLSchema({ query, mutation })

registerAliasFieldResolvers(schema)

const schemaValidationErrors = validateSchema(schema)
if (schemaValidationErrors.length) {
schemaValidationErrors.forEach(error => (error.severity = 'Error')) // Needed for cds-dk to decide logging based on log level
throw new AggregateError(schemaValidationErrors, 'GraphQL schema validation failed')
}

return schema
}

Expand Down
6 changes: 3 additions & 3 deletions lib/schema/mutation.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = cache => {
if (type) fields[serviceName] = { type, resolve }
}

if (Object.keys(fields).length === 0) return
if (!Object.keys(fields).length) return

return new GraphQLObjectType({ name: 'Mutation', fields })
}
Expand All @@ -36,7 +36,7 @@ module.exports = cache => {
if (type) fields[entityName] = { type }
}

if (Object.keys(fields).length === 0) return
if (!Object.keys(fields).length) return

return new GraphQLObjectType({ name: gqlName(service.name) + '_input', fields })
}
Expand All @@ -51,7 +51,7 @@ module.exports = cache => {
}).filter(([_, v]) => v)
)

if (Object.keys(fields).length === 0) return
if (!Object.keys(fields).length) return

return new GraphQLObjectType({ name: gqlName(entity.name) + '_input', fields })
}
Expand Down
27 changes: 22 additions & 5 deletions lib/schema/query.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
const cds = require('@sap/cds')
const LOG = cds.log('graphql')
const { GraphQLObjectType } = require('graphql')
const { gqlName } = require('../utils')
const objectGenerator = require('./types/object')
const argsGenerator = require('./args')
const { isCompositionOfAspect } = require('./util')
const { GraphQLVoid } = require('./types/custom')

module.exports = cache => {
const generateQueryObjectType = (services, resolvers) => {
const name = 'Query'
const fields = {}

for (const key in services) {
const service = services[key]
const serviceName = gqlName(service.name)
fields[serviceName] = {
type: _serviceToObjectType(service),
resolve: resolvers[serviceName]
}
const type = _serviceToObjectType(service)
if (!type) continue
fields[serviceName] = { type, resolve: resolvers[serviceName] }
}

return new GraphQLObjectType({ name: 'Query', fields })
// Empty root query object type is not allowed, so we add a placeholder field
if (!Object.keys(fields).length) {
fields._ = { type: GraphQLVoid }
LOG.warn(
`Root query object type "${name}" is empty. A placeholder field has been added to ensure a valid schema.`
)
}

return new GraphQLObjectType({ name, fields })
}

const _serviceToObjectType = service => {
Expand All @@ -30,11 +41,17 @@ module.exports = cache => {

// REVISIT: requires differentiation for support of configurable schema flavors
const type = objectGenerator(cache).entityToObjectConnectionType(entity)
if (!type) continue
const args = argsGenerator(cache).generateArgumentsForType(entity)

fields[gqlName(key)] = { type, args }
}

if (!Object.keys(fields).length) {
LOG.warn(`Service "${service.name}" has no fields and has therefore been excluded from the schema.`)
return
}

return new GraphQLObjectType({
name: gqlName(service.name),
// REVISIT: Passed services currently don't directly contain doc property
Expand Down
15 changes: 15 additions & 0 deletions lib/schema/types/custom/GraphQLVoid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const { GraphQLScalarType } = require('graphql')

const serialize = () => null

const parseValue = () => null

const parseLiteral = () => null

module.exports = new GraphQLScalarType({
name: 'Void',
description: 'The `Void` scalar type represents the absence of a value. Void can only represent the value `null`.',
serialize,
parseValue,
parseLiteral
})
3 changes: 2 additions & 1 deletion lib/schema/types/custom/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ module.exports = {
GraphQLInt64: require('./GraphQLInt64'),
GraphQLTime: require('./GraphQLTime'),
GraphQLTimestamp: require('./GraphQLTimestamp'),
GraphQLUInt8: require('./GraphQLUInt8')
GraphQLUInt8: require('./GraphQLUInt8'),
GraphQLVoid: require('./GraphQLVoid')
}
45 changes: 29 additions & 16 deletions lib/schema/types/object.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const cds = require('@sap/cds')
const LOG = cds.log('graphql')
const { GraphQLObjectType, GraphQLList, GraphQLInt } = require('graphql')
const { gqlName } = require('../../utils')
const argsGenerator = require('../args')
Expand All @@ -9,46 +11,57 @@ module.exports = cache => {
const entityToObjectConnectionType = entity => {
const name = gqlName(entity.name) + '_connection'

const cachedEntityObjectConnectionType = cache.get(name)
if (cachedEntityObjectConnectionType) return cachedEntityObjectConnectionType
if (cache.has(name)) return cache.get(name)

const fields = {}
const newEntityObjectConnectionType = new GraphQLObjectType({ name, fields: () => fields })
cache.set(name, newEntityObjectConnectionType)
const entityObjectConnectionType = new GraphQLObjectType({ name, fields: () => fields })
cache.set(name, entityObjectConnectionType)

fields[CONNECTION_FIELDS.nodes] = { type: new GraphQLList(entityToObjectType(entity)) }
const objectType = entityToObjectType(entity)
if (!objectType) {
cache.set(name)
return
}

fields[CONNECTION_FIELDS.nodes] = { type: new GraphQLList(objectType) }
fields[CONNECTION_FIELDS.totalCount] = { type: GraphQLInt }

return newEntityObjectConnectionType
return entityObjectConnectionType
}

const entityToObjectType = entity => {
const entityName = gqlName(entity.name)

const cachedEntityObjectType = cache.get(entityName)
if (cachedEntityObjectType) return cachedEntityObjectType
if (cache.has(entityName)) return cache.get(entityName)

const fields = {}
const newEntityObjectType = new GraphQLObjectType({
const entityObjectType = new GraphQLObjectType({
name: entityName,
description: entity.doc,
fields: () => fields
})
cache.set(entityName, newEntityObjectType)
cache.set(entityName, entityObjectType)

for (const name in entity.elements) {
const element = entity.elements[name]

// REVISIT: requires differentiation for support of configurable schema flavors
const type = element.is2many ? _elementToObjectConnectionType(element) : _elementToObjectType(element)
if (type) {
const field = { type, description: element.doc }
if (element.is2many) field.args = argsGenerator(cache).generateArgumentsForType(element)
fields[gqlName(name)] = field
}
if (!type) continue

const field = { type, description: element.doc }
if (element.is2many) field.args = argsGenerator(cache).generateArgumentsForType(element)
fields[gqlName(name)] = field
}

// fields is empty e.g. for empty aspects
if (!Object.keys(fields).length) {
LOG.warn(`Entity "${entity.name}" has no fields and has therefore been excluded from the schema.`)
cache.set(entityName, undefined)
return
}

return newEntityObjectType
return entityObjectType
}

const _elementToObjectType = element => {
Expand Down
8 changes: 8 additions & 0 deletions test/resources/empty-csn-definitions/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"dependencies": {
"@cap-js/graphql": "*"
},
"devDependencies": {
"@cap-js/sqlite": "*"
}
}
Loading