Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @cap-js/node-js-runtime
3 changes: 1 addition & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ jobs:
- name: Create a GitHub release
uses: ncipollo/release-action@v1
with:
tag: "v${{ steps.package-version.outputs.current-version}}"
name: "Release v${{ steps.package-version.outputs.current-version}}"
tag: "v${{ steps.package-version.outputs.current-version }}"
# body: changelog...
- run: npm publish --access public
env:
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Version 0.2.0 - 2023-01-30

### Changed

- Register `aliasFieldResolver` during schema generation instead of passing it to the GraphQL server
- The filters `contains`, `startswith`, and `endswith` now generate CQN function calls instead of generating `like` expressions directly

### Fixed

- Schema generation crash that occurred if an entity property is named `localized`
- The field `totalCount` could not be queried on its own

## Version 0.1.0 - 2022-12-08

### Added
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,17 @@ _**WARNING:** This package is in an early general availability state. This means
npm add @cap-js/graphql
```

2. Enable [middlewares](https://cap.cloud.sap/docs/node.js/middlewares) and the GraphQL adapter in your project's `package.json`:
2. Register the GraphQL adapter in your project's `package.json`:
```jsonc
{
"cds": {
"requires": {
"middlewares": true
},
"protocols": {
"graphql": { "path": "/graphql", "impl": "@cap-js/graphql" }
}
}
}
```
> Note: This automatically enables the new [middlewares architecture](https://cap.cloud.sap/docs/node.js/middlewares) in Node.js.

3. Run your server as usual, e.g. using `cds watch`.
> The runtime will automatically serve all services via GraphQL at the default configured endpoint.
Expand Down
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const config = {
testTimeout: 10000
}

module.exports = config
3 changes: 1 addition & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
const { graphqlHTTP } = require('express-graphql')

const { generateSchema4 } = require('./schema')
const { fieldResolver } = require('./resolvers')

function GraphQLAdapter(services, options) {
const schema = generateSchema4(services)
return graphqlHTTP({ fieldResolver, schema, ...options })
return graphqlHTTP({ schema, ...options })
}

module.exports = GraphQLAdapter
16 changes: 15 additions & 1 deletion lib/resolvers/field.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
const { isObjectType, isIntrospectionType } = require('graphql')

// The GraphQL.js defaultFieldResolver does not support returning aliased values that resolve to fields with aliases
module.exports = (source, args, context, info) => {
function aliasFieldResolver(source, args, contextValue, info) {
const responseKey = info.fieldNodes[0].alias ? info.fieldNodes[0].alias.value : info.fieldName
return source[responseKey]
}

const registerAliasFieldResolvers = schema => {
for (const type of Object.values(schema.getTypeMap())) {
if (!isObjectType(type) || isIntrospectionType(type)) continue

for (const field of Object.values(type.getFields())) {
if (!field.resolve) field.resolve = aliasFieldResolver
}
}
}

module.exports = registerAliasFieldResolvers
4 changes: 2 additions & 2 deletions lib/resolvers/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const fieldResolver = require('./field')
const registerAliasFieldResolvers = require('./field')
const createRootResolvers = require('./root')

module.exports = {
fieldResolver,
registerAliasFieldResolvers,
createRootResolvers
}
10 changes: 5 additions & 5 deletions lib/resolvers/parse/ast/enrich.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ const _traverseArgumentOrObjectField = (info, argumentOrObjectField, _fieldOr_ar
argumentOrObjectField.value = substituteVariable(info, value)
break
case Kind.LIST:
_traverseListValue(info, value, type._fields)
_traverseListValue(info, value, type.getFields())
break
case Kind.OBJECT:
_traverseObjectValue(info, value, type._fields)
_traverseObjectValue(info, value, type.getFields())
break
}
}
Expand All @@ -60,7 +60,7 @@ const _getTypeFrom_fieldOr_arg = _field => {
const _traverseField = (info, field, _field) => {
if (field.selectionSet) {
const type = _getTypeFrom_fieldOr_arg(_field)
_traverseSelectionSet(info, field.selectionSet, type._fields)
_traverseSelectionSet(info, field.selectionSet, type.getFields())
}

field.arguments.forEach(arg => {
Expand All @@ -79,8 +79,8 @@ module.exports = info => {
const deepClonedFieldNodes = JSON.parse(JSON.stringify(info.fieldNodes))

const rootTypeName = info.parentType.name
const rootType = info.schema._typeMap[rootTypeName]
_traverseFieldNodes(info, deepClonedFieldNodes, rootType._fields)
const rootType = info.schema.getType(rootTypeName)
_traverseFieldNodes(info, deepClonedFieldNodes, rootType.getFields())

return deepClonedFieldNodes
}
3 changes: 1 addition & 2 deletions lib/resolvers/parse/ast/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,8 @@ const formatResult = (field, result, skipTopLevelConnection) => {
return _formatArray(field, value, skipTopLevelConnection)
} else if (isPlainObject(value)) {
return _formatObject(field.selectionSet.selections, value)
} else {
return value
}
return value
}

return _formatByType(field, result, skipTopLevelConnection)
Expand Down
2 changes: 1 addition & 1 deletion lib/resolvers/parse/ast/util/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
const isPlainObject = value => value !== null && typeof value === 'object' && !Buffer.isBuffer(value)

module.exports = { isPlainObject }
module.exports = { isPlainObject }
25 changes: 9 additions & 16 deletions lib/resolvers/parse/ast2cqn/where.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ const { RELATIONAL_OPERATORS, STRING_OPERATIONS } = require('../../../constants'
const { Kind } = require('graphql')

const GQL_TO_CDS_STRING_OPERATIONS = {
[STRING_OPERATIONS.startswith]: 'like',
[STRING_OPERATIONS.endswith]: 'like',
[STRING_OPERATIONS.contains]: 'like'
[STRING_OPERATIONS.startswith]: 'startswith',
[STRING_OPERATIONS.endswith]: 'endswith',
[STRING_OPERATIONS.contains]: 'contains'
}

const GQL_TO_CDS_QL_OPERATOR = {
Expand All @@ -16,26 +16,19 @@ const GQL_TO_CDS_QL_OPERATOR = {
[RELATIONAL_OPERATORS.lt]: '<'
}

const _stringOperationToLikeString = (operator, string) =>
({
[STRING_OPERATIONS.startswith]: `${string}%`,
[STRING_OPERATIONS.endswith]: `%${string}`,
[STRING_OPERATIONS.contains]: `%${string}%`
}[operator])

const _gqlValueToCdsValue = (cdsOperator, gqlOperator, gqlValue) =>
cdsOperator === 'like' ? _stringOperationToLikeString(gqlOperator, gqlValue) : gqlValue

const _gqlOperatorToCdsOperator = gqlOperator =>
GQL_TO_CDS_QL_OPERATOR[gqlOperator] || GQL_TO_CDS_STRING_OPERATIONS[gqlOperator]

const _objectFieldTo_xpr = (objectField, columnName) => {
const gqlOperator = objectField.name.value
const cdsOperator = _gqlOperatorToCdsOperator(gqlOperator)
const gqlValue = objectField.value.value
const cdsValue = _gqlValueToCdsValue(cdsOperator, gqlOperator, gqlValue)

return [{ ref: [columnName] }, cdsOperator, { val: cdsValue }]
const ref = { ref: [columnName] }
const val = { val: objectField.value.value }

if (STRING_OPERATIONS[gqlOperator]) return [{ func: cdsOperator, args: [ref, val] }]

return [ref, cdsOperator, val]
}

const _parseObjectField = (objectField, columnName) => {
Expand Down
5 changes: 2 additions & 3 deletions lib/resolvers/parse/util/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ const _filterOutDuplicateColumnsSelections = selections => {
}

const getPotentiallyNestedNodesSelections = selections => {
const nodesSelections = selections.filter(selection => selection.name.value === CONNECTION_FIELDS.nodes)
if (nodesSelections.length === 0) return selections
return _filterOutDuplicateColumnsSelections(selections)
const isConnection = selections.some(selection => Object.values(CONNECTION_FIELDS).includes(selection.name.value))
return isConnection ? _filterOutDuplicateColumnsSelections(selections) : selections
}

module.exports = { getPotentiallyNestedNodesSelections }
25 changes: 13 additions & 12 deletions lib/resolvers/root.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,24 @@ const resolveQuery = require('./query')
const resolveMutation = require('./mutation')
const { enrichAST } = require('./parse/ast')

const _wrapResolver = (service, resolver) => (root, args, context, info) => {
const response = {}
const _wrapResolver = (service, resolver) =>
function CDSRootResolver(root, args, context, info) {
const response = {}

const enrichedFieldNodes = enrichAST(info)
const enrichedFieldNodes = enrichAST(info)

for (const fieldNode of enrichedFieldNodes) {
for (const field of fieldNode.selectionSet.selections) {
const fieldName = field.name.value
const entity = service.entities[fieldName]
const responseKey = field.alias?.value || fieldName
for (const fieldNode of enrichedFieldNodes) {
for (const field of fieldNode.selectionSet.selections) {
const fieldName = field.name.value
const entity = service.entities[fieldName]
const responseKey = field.alias?.value || fieldName

response[responseKey] = resolver(service, entity, field)
response[responseKey] = resolver(service, entity, field)
}
}
}

return response
}
return response
}

module.exports = services => {
const Query = {}
Expand Down
3 changes: 2 additions & 1 deletion lib/schema/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const queryGenerator = require('./query')
const mutationGenerator = require('./mutation')
const { GraphQLSchema, printSchema } = require('graphql')
const { createRootResolvers } = require('../resolvers')
const { createRootResolvers, registerAliasFieldResolvers } = require('../resolvers')

class SchemaGenerator {
generate(services) {
Expand All @@ -10,6 +10,7 @@ class SchemaGenerator {
const query = queryGenerator(cache).generateQueryObjectType(services, resolvers.Query)
const mutation = mutationGenerator(cache).generateMutationObjectType(services, resolvers.Mutation)
this._schema = new GraphQLSchema({ query, mutation })
registerAliasFieldResolvers(this._schema)
return this
}

Expand Down
2 changes: 1 addition & 1 deletion lib/schema/types/custom/GraphQLBinary.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { GraphQLScalarType, Kind, GraphQLError } = require('graphql')
const { getGraphQLValueError } = require('./util.js')
const { getGraphQLValueError } = require('./util')

const ERROR_NON_STRING_VALUE = 'Binary cannot represent non string value'
const ERROR_NON_BASE64_OR_BASE64URL = 'Binary values must be base64 or base64url encoded and normalized strings'
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/types/custom/GraphQLDate.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { GraphQLScalarType, Kind } = require('graphql')
const { getGraphQLValueError, parseDate } = require('./util.js')
const { getGraphQLValueError, parseDate } = require('./util')

const ERROR_NON_STRING_VALUE = 'Date cannot represent non string value'
const ERROR_NON_DATE_VALUE = 'Date values must be strings in the ISO 8601 format YYYY-MM-DD'
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/types/custom/GraphQLDateTime.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { GraphQLScalarType, Kind } = require('graphql')
const { getGraphQLValueError, parseDate } = require('./util.js')
const { getGraphQLValueError, parseDate } = require('./util')

const ERROR_NON_STRING_VALUE = 'DateTime cannot represent non string value'
const ERROR_NON_DATE_TIME_VALUE = 'DateTime values must be strings in the ISO 8601 format YYYY-MM-DDThh-mm-ssTZD'
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/types/custom/GraphQLDecimal.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { GraphQLScalarType, Kind } = require('graphql')
const { getValueFromInputValueOrValueNode, getGraphQLValueError } = require('./util.js')
const { getValueFromInputValueOrValueNode, getGraphQLValueError } = require('./util')

const ERROR_VARIABLE_NON_STRING_VALUE = 'Decimal variable value must be represented by a string'
const ERROR_NON_NUMERIC_VALUE = 'Decimal must be a numeric value'
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/types/custom/GraphQLInt16.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { GraphQLScalarType, Kind } = require('graphql')
const { getGraphQLValueError, validateRange } = require('./util.js')
const { getGraphQLValueError, validateRange } = require('./util')

const ERROR_NON_INTEGER_VALUE = 'Int16 cannot represent non integer value'
const ERROR_NON_16_BIT_INTEGER_VALUE = 'Int16 must be an integer value between -(2^15) and 2^15 - 1'
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/types/custom/GraphQLInt64.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { GraphQLScalarType, Kind } = require('graphql')
const { getValueFromInputValueOrValueNode, getGraphQLValueError, validateRange } = require('./util.js')
const { getValueFromInputValueOrValueNode, getGraphQLValueError, validateRange } = require('./util')

const ERROR_VARIABLE_NON_STRING_VALUE = 'Int64 variable value must be represented by a string'
const ERROR_NON_INTEGER_VALUE = 'Int64 cannot represent non integer value'
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/types/custom/GraphQLTime.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { GraphQLScalarType, Kind } = require('graphql')
const { getGraphQLValueError, getValueFromInputValueOrValueNode, ISO_TIME_REGEX } = require('./util.js')
const { getGraphQLValueError, getValueFromInputValueOrValueNode, ISO_TIME_REGEX } = require('./util')

const ERROR_NON_STRING_VALUE = 'Time cannot represent non string value'
const ERROR_NON_TIME_VALUE = 'Time values must be strings in the ISO 8601 format hh:mm:ss'
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/types/custom/GraphQLTimestamp.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { GraphQLScalarType, Kind } = require('graphql')
const { getGraphQLValueError, parseDate } = require('./util.js')
const { getGraphQLValueError, parseDate } = require('./util')

const ERROR_NON_STRING_VALUE = 'Timestamp cannot represent non string value'
const ERROR_NON_TIMESTAMP_VALUE =
Expand Down
4 changes: 2 additions & 2 deletions lib/schema/types/custom/GraphQLUInt8.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
const { GraphQLScalarType, Kind } = require('graphql')
const { getGraphQLValueError, validateRange } = require('./util.js')
const { getGraphQLValueError, validateRange } = require('./util')

const ERROR_NON_INTEGER_VALUE = 'UInt8 cannot represent non integer value'
const ERROR_NON_8_BIT_UNSIGNED_INTEGER_VALUE = 'UInt8 must be an integer value between 0 and 2^8 - 1'

const MAX_UINT8 = 255 // 2^8 - 1
const MIN_UINT8 = 0 // 2^0 - 1
const MIN_UINT8 = 0 // 0

const parseValue = inputValue => {
if (typeof inputValue !== 'number') throw getGraphQLValueError(ERROR_NON_INTEGER_VALUE, inputValue)
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/util/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const hasScalarFields = entity =>
([, el]) => !(shouldElementBeIgnored(el) || el.isAssociation || el.isComposition)
)

const _isLocalized = element => element.name === 'localized' && element.target.endsWith('.texts')
const _isLocalized = element => element.name === 'localized' && element.target?.endsWith('.texts')

const shouldElementBeIgnored = element => element.name.startsWith('up_') || _isLocalized(element)

Expand Down
6 changes: 2 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cap-js/graphql",
"version": "0.1.0",
"version": "0.2.0",
"description": "CDS protocol adapter for GraphQL",
"keywords": [
"CAP",
Expand All @@ -9,6 +9,7 @@
],
"author": "SAP SE (https://www.sap.com)",
"license": "SEE LICENSE IN LICENSE",
"repository": "cap-js/cds-adapter-graphql",
"homepage": "https://cap.cloud.sap/",
"main": "index.js",
"files": [
Expand Down Expand Up @@ -43,8 +44,5 @@
"jest": "^29.3.1",
"prettier": "^2.3.0",
"sqlite3": "^5.0.2"
},
"jest": {
"testTimeout": 10000
}
}
7 changes: 0 additions & 7 deletions test/resources/bookshop-graphql/package.json

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ const cds = require('@sap/cds')
const path = require('path')

cds.env.protocols = {
graphql: { path: '/graphql', impl: path.join(__dirname, '../../../../index.js') }
}
graphql: { path: '/graphql', impl: path.join(__dirname, '../../../index.js') }
}
Loading