diff --git a/spec/model/create-model.spec.ts b/spec/model/create-model.spec.ts index f62c00fa..c2dcd3eb 100644 --- a/spec/model/create-model.spec.ts +++ b/spec/model/create-model.spec.ts @@ -2,6 +2,10 @@ import { expect } from 'chai'; import { DocumentNode } from 'graphql'; import gql from 'graphql-tag'; import { createSimpleModel } from './model-spec.helper'; +import { parseProject } from '../../src/schema/schema-builder'; +import { Project, ProjectSource } from '../../core-exports'; +import { Severity, ValidationContext, createModel } from '../../src/model'; +import { expectSingleErrorToInclude, expectToBeValid } from './implementation/validation-utils'; describe('createModel', () => { it('translates _key: String @key properly', () => { @@ -376,4 +380,84 @@ describe('createModel', () => { expect(type.getFieldOrThrow('createdAt').isHidden).to.be.false; expect(type.getFieldOrThrow('test2').isHidden).to.be.true; }); + + describe('with modules', () => { + it('extracts the module definitions', () => { + const validationContext = new ValidationContext(); + const parsedProject = parseProject( + new Project([ + new ProjectSource( + 'modules.json', + JSON.stringify({ + modules: ['module1', 'module2'], + }), + ), + ]), + validationContext, + ); + expectToBeValid(validationContext.asResult()); + const model = createModel(parsedProject, { + withModuleDefinitions: true, + }); + expectToBeValid(model); + const modules = model.modules; + expect(modules).to.have.lengthOf(2); + const module1 = modules[0]; + expect(module1.name).to.equal('module1'); + expect(module1.loc?.start.offset).to.equal(12); + expect(module1.loc?.end.offset).to.equal(21); + }); + + it('does not allow module declarations if withModuleDeclarations is not set', () => { + const validationContext = new ValidationContext(); + const parsedProject = parseProject( + new Project([ + new ProjectSource( + 'modules.json', + JSON.stringify({ + modules: ['module1', 'module2'], + }), + ), + ]), + validationContext, + ); + expectToBeValid(validationContext.asResult()); + const model = createModel(parsedProject); + expectSingleErrorToInclude( + model, + 'Module declarations are not supported in this context.', + ); + const modules = model.modules; + expect(modules).to.have.lengthOf(0); + }); + + it('does not allow duplicate module names', () => { + const validationContext = new ValidationContext(); + const parsedProject = parseProject( + new Project([ + new ProjectSource( + 'modules.json', + JSON.stringify({ + modules: ['module1', 'module1'], + }), + ), + ]), + validationContext, + ); + expectToBeValid(validationContext.asResult()); + const model = createModel(parsedProject, { + withModuleDefinitions: true, + }); + const validationResult = model.validate(); + expect(validationResult.messages.length).to.equal(2); + expect(validationResult.messages[0].severity).to.equal(Severity.ERROR); + expect(validationResult.messages[0].message).to.equal( + 'Duplicate module declaration: "module1".', + ); + expect(validationResult.messages[1].severity).to.equal(Severity.ERROR); + expect(validationResult.messages[1].message).to.equal( + 'Duplicate module declaration: "module1".', + ); + }); + }); }); diff --git a/spec/model/implementation/validation-utils.ts b/spec/model/implementation/validation-utils.ts index 6dccfe3d..9b5977e7 100644 --- a/spec/model/implementation/validation-utils.ts +++ b/spec/model/implementation/validation-utils.ts @@ -5,27 +5,36 @@ import { ValidationContext, } from '../../../src/model/validation/validation-context'; -export function validate(component: ModelComponent): ValidationResult { +export function validate(component: ModelComponent | ValidationResult): ValidationResult { + if (component instanceof ValidationResult) { + return component; + } const context = new ValidationContext(); component.validate(context); return context.asResult(); } -export function expectToBeValid(component: ModelComponent) { +export function expectToBeValid(component: ModelComponent | ValidationResult) { const result = validate(component); expect(result.hasMessages(), result.toString()).to.be.false; } -export function expectSingleErrorToInclude(component: ModelComponent, errorPart: string) { +export function expectSingleErrorToInclude( + component: ModelComponent | ValidationResult, + errorPart: string, +) { expectSingleMessageToInclude(component, errorPart, Severity.ERROR); } -export function expectSingleWarningToInclude(component: ModelComponent, errorPart: string) { +export function expectSingleWarningToInclude( + component: ModelComponent | ValidationResult, + errorPart: string, +) { expectSingleMessageToInclude(component, errorPart, Severity.WARNING); } export function expectSingleMessageToInclude( - component: ModelComponent, + component: ModelComponent | ValidationResult, errorPart: string, severity: Severity, ) { diff --git a/spec/schema/ast-validation-modules/helpers.ts b/spec/schema/ast-validation-modules/helpers.ts index 6469e44a..71dce431 100644 --- a/spec/schema/ast-validation-modules/helpers.ts +++ b/spec/schema/ast-validation-modules/helpers.ts @@ -53,6 +53,7 @@ export function assertValidatorAcceptsAndDoesNotWarn( export interface ValidationOptions { readonly permissionProfiles?: PermissionProfileConfigMap; timeToLive?: ReadonlyArray; + withModuleDefinitions?: boolean; } export function validate( @@ -81,33 +82,39 @@ export function validate( return intermediateResult; } - const model = createModel({ - sources: [ - { - kind: ParsedProjectSourceBaseKind.GRAPHQL, - document: ast, - namespacePath: [], - }, - { - kind: ParsedProjectSourceBaseKind.OBJECT, - object: { - permissionProfiles: options.permissionProfiles || { - default: { - permissions: [ - { - roles: ['admin'], - access: 'readWrite', - }, - ], + const model = createModel( + { + sources: [ + { + kind: ParsedProjectSourceBaseKind.GRAPHQL, + document: ast, + namespacePath: [], + }, + { + kind: ParsedProjectSourceBaseKind.OBJECT, + object: { + permissionProfiles: options.permissionProfiles || { + default: { + permissions: [ + { + roles: ['admin'], + access: 'readWrite', + }, + ], + }, }, + timeToLive: options.timeToLive, + modules: options.withModuleDefinitions + ? ['module1', 'module2', 'module3'] + : undefined, }, - timeToLive: options.timeToLive, + namespacePath: [], + pathLocationMap: {}, }, - namespacePath: [], - pathLocationMap: {}, - }, - ], - }); + ], + }, + { withModuleDefinitions: options.withModuleDefinitions }, + ); const astResults = validatePostMerge(ast, model); return new ValidationResult([ diff --git a/spec/schema/ast-validation-modules/modules-validator.spec.ts b/spec/schema/ast-validation-modules/modules-validator.spec.ts new file mode 100644 index 00000000..e0466c13 --- /dev/null +++ b/spec/schema/ast-validation-modules/modules-validator.spec.ts @@ -0,0 +1,410 @@ +import { assertValidatorAcceptsAndDoesNotWarn, assertValidatorRejects } from './helpers'; + +describe('modules validator', () => { + describe('using decorator without withModuleDefinitions', () => { + it('does not allow @modules on a type if withModuleDefinitions is false', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1", "module2"]) { + foo: String + } + `, + 'Module specifications are not supported in this context.', + ); + }); + + it('does not allow @modules on a field if withModuleDefinitions is false', () => { + assertValidatorRejects( + ` + type Foo @rootEntity { + foo: String @modules(all: true) + } + `, + 'Module specifications are not supported in this context.', + ); + }); + }); + + describe('on object types', () => { + it('accepts a simple @modules', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1", "module2"]) { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts @modules(in: "")', () => { + // this is allowed by graphql + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: "module1") { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts a @modules with an empty module list', () => { + // allowed so you can temporarily remove all modules for testing purposes + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: []) { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts a @modules with an empty module list', () => { + // allowed so you can temporarily remove all modules for testing purposes + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: []) { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('does not allow @modules(all: ...)', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1"], all: true) { + foo: String @modules(in: ["module1"]) + } + `, + '"all" can only be specified on field declarations.', + { withModuleDefinitions: true }, + ); + }); + + it('rejects a missing @modules on a root entity type', () => { + assertValidatorRejects( + ` + type Foo @rootEntity { + foo: String @modules(all: true) + } + `, + 'Missing module specification. Add modules(in: ...) to specify the modules of this root entity type.', + { withModuleDefinitions: true }, + ); + }); + + it('accepts a missing @modules on a child entity type', () => { + // might want to enforce this in the future, but for now, non-root-entity types are just included in all modules that somehow use them + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: "module1") { + bar: [Bar] @modules(all: true) + } + + type Bar @childEntity { + foo: String + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts a missing @modules on an entity extension type', () => { + // might want to enforce this in the future, but for now, non-root-entity types are just included in all modules that somehow use them + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: "module1") { + bar: Bar @modules(all: true) + } + + type Bar @entityExtension { + foo: String + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts a missing @modules on a value object type', () => { + // might want to enforce this in the future, but for now, non-root-entity types are just included in all modules that somehow use them + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: "module1") { + bar: Bar @modules(all: true) + } + + type Bar @valueObject { + foo: String + } + `, + { withModuleDefinitions: true }, + ); + }); + }); + + describe('on fields', () => { + it('accepts @modules(all: true)', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1", "module2"]) { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts @modules(in: ...)', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1", "module2"]) { + foo: String @modules(in: ["module1"]) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts a @modules with an empty module list', () => { + // basically means that the field is not available anywhere - can be used for testing + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1"]) { + foo: String @modules(in: []) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts without @modules on the field if includeAllFields is true on the type', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1"], includeAllFields: true) { + foo: String + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('rejects @modules with neither all nor in', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1", "module2"]) { + foo: String @modules + } + `, + 'Either "all" or "in" needs to be specified.', + { withModuleDefinitions: true }, + ); + }); + + it('rejects @modules with both all and in', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1", "module2"]) { + foo: String @modules(all: true, in: ["module1"]) + } + `, + '"all" and "in" cannot be combined.', + { withModuleDefinitions: true }, + ); + }); + + it('rejects missing @modules', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1", "module2"]) { + foo: String + } + `, + 'Missing module specification. Either add @modules on field "foo", or specify @modules(includeAllFields: true) on type "Foo".', + { withModuleDefinitions: true }, + ); + }); + + it('rejects @modules on a field if includeAllFields is true on the type', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1"], includeAllFields: true) { + foo: String @modules(all: true) + } + `, + '@modules cannot be specified here because @modules(includeAllFields: true) is specified on type "Foo", and therefore @modules(all: true) is implicitly configured for all its fields.', + { withModuleDefinitions: true }, + ); + }); + + it('rejects @modules on the id field', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1"]) { + foo: String @modules(all: true) + id: ID @modules(all: true) + } + `, + 'Directive "@modules" is not allowed on system field "id" and will be discarded', + { withModuleDefinitions: true }, + ); + }); + + it('accepts the id field without @modules', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1"]) { + foo: String @modules(all: true) + id: ID @key + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('does not allow @modules(includeAllFields: ...)', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1"]) { + foo: String @modules(all: true, includeAllFields: true) + } + `, + '"includeAllFields" can only be specified on type declarations.', + { withModuleDefinitions: true }, + ); + }); + }); + + describe('expressions', () => { + it('accepts a single module', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1"]) { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts an and combination of two modules', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1 && module2"]) { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts an and combination of two modules without space', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1&&module2"]) { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('accepts an and combination of three modules', () => { + assertValidatorAcceptsAndDoesNotWarn( + ` + type Foo @rootEntity @modules(in: ["module1 && module2 && module3"]) { + foo: String @modules(all: true) + } + `, + { withModuleDefinitions: true }, + ); + }); + + it('rejects a module that does not exist', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["doesNotExist"]) { + foo: String @modules(all: true) + } + `, + 'Module "doesNotExist" does not exist.', + { withModuleDefinitions: true }, + ); + }); + + it('rejects an expression with a singular &', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1 & module2"]) { + foo: String @modules(all: true) + } + `, + 'Expected "&&", but only got single "&".', + { withModuleDefinitions: true }, + ); + }); + + it('rejects an expression with just two identifiers next to each other', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1 module2"]) { + foo: String @modules(all: true) + } + `, + 'Expected "&&", but got "m".', + { withModuleDefinitions: true }, + ); + }); + + it('rejects an expression that ends with &&', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1 &&"]) { + foo: String @modules(all: true) + } + `, + 'Expected identifier.', + { withModuleDefinitions: true }, + ); + }); + + it('rejects an expression that ends with &', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1 &"]) { + foo: String @modules(all: true) + } + `, + 'Expected "&&", but only got single "&".', + { withModuleDefinitions: true }, + ); + }); + + it('rejects an expression that starts with &&', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["&& module1"]) { + foo: String @modules(all: true) + } + `, + 'Expected identifier, but got "&".', + { withModuleDefinitions: true }, + ); + }); + + it('rejects an expression that includes an invalid character', () => { + assertValidatorRejects( + ` + type Foo @rootEntity @modules(in: ["module1!elf"]) { + foo: String @modules(all: true) + } + `, + 'Expected identifier or "&&", but got "!".', + { withModuleDefinitions: true }, + ); + }); + }); +}); diff --git a/src/config/interfaces.ts b/src/config/interfaces.ts index eff8c3a2..4110f291 100644 --- a/src/config/interfaces.ts +++ b/src/config/interfaces.ts @@ -54,6 +54,16 @@ export interface ModelOptions { * A list of root entity names that are not allowed. */ readonly forbiddenRootEntityNames?: ReadonlyArray; + + /** + * If set to true, the project is expected to define modules and assign them to types and fields using the @modules directive + */ + readonly withModuleDefinitions?: boolean; + + /** + * The modules to consider when parsing this project. Only relevant if withModuleDefinitions is true. + */ + readonly selectedModules?: ReadonlyArray } export interface ProjectOptions { diff --git a/src/model/config/field.ts b/src/model/config/field.ts index d7612faf..9a6ced68 100644 --- a/src/model/config/field.ts +++ b/src/model/config/field.ts @@ -8,6 +8,7 @@ import { ValueNode, } from 'graphql'; import { PermissionsConfig } from './permissions'; +import { FieldModuleSpecificationConfig } from './module-specification'; export interface FieldConfig { readonly name: string; @@ -74,6 +75,8 @@ export interface FieldConfig { */ readonly isHidden?: boolean; readonly isHiddenASTNode?: DirectiveNode; + + readonly moduleSpecification?: FieldModuleSpecificationConfig; } export enum RelationDeleteAction { diff --git a/src/model/config/model.ts b/src/model/config/model.ts index 6e52172f..6f4e1145 100644 --- a/src/model/config/model.ts +++ b/src/model/config/model.ts @@ -3,6 +3,7 @@ import { ValidationMessage } from '../validation'; import { BillingConfig } from './billing'; import { LocalizationConfig } from './i18n'; import { NamespacedPermissionProfileConfigMap, TimeToLiveConfig } from './index'; +import { ModuleConfig } from './module'; import { TypeConfig } from './type'; export interface ModelConfig { @@ -13,4 +14,5 @@ export interface ModelConfig { readonly billing?: BillingConfig; readonly timeToLiveConfigs?: ReadonlyArray; readonly options?: ModelOptions; + readonly modules: ReadonlyArray; } diff --git a/src/model/config/module-specification.ts b/src/model/config/module-specification.ts new file mode 100644 index 00000000..1ca6105a --- /dev/null +++ b/src/model/config/module-specification.ts @@ -0,0 +1,22 @@ +import { ASTNode } from 'graphql'; + +export interface ModuleSpecificationClauseConfig { + readonly expression: string; + readonly astNode?: ASTNode; +} + +export interface BaseModuleSpecificationConfig { + readonly in?: ReadonlyArray; + readonly astNode?: ASTNode; + readonly inAstNode?: ASTNode; +} + +export interface TypeModuleSpecificationConfig extends BaseModuleSpecificationConfig { + readonly includeAllFields: boolean; + readonly includeAllFieldsAstNode?: ASTNode; +} + +export interface FieldModuleSpecificationConfig extends BaseModuleSpecificationConfig { + readonly all: boolean; + readonly allAstNode?: ASTNode; +} diff --git a/src/model/config/module.ts b/src/model/config/module.ts new file mode 100644 index 00000000..6c2c1e70 --- /dev/null +++ b/src/model/config/module.ts @@ -0,0 +1,6 @@ +import { MessageLocation } from '../validation'; + +export interface ModuleConfig { + readonly name: string; + readonly loc?: MessageLocation; +} diff --git a/src/model/config/type.ts b/src/model/config/type.ts index 89ddef9a..83cfc32d 100644 --- a/src/model/config/type.ts +++ b/src/model/config/type.ts @@ -10,8 +10,8 @@ import { import { FixedPointDecimalInfo } from '../implementation/scalar-type'; import { FieldConfig, FlexSearchLanguage } from './field'; import { FlexSearchIndexConfig, IndexDefinitionConfig } from './indices'; +import { TypeModuleSpecificationConfig } from './module-specification'; import { PermissionsConfig } from './permissions'; -import { TimeToLiveConfig } from './time-to-live'; export enum TypeKind { SCALAR = 'SCALAR', @@ -34,6 +34,7 @@ export interface TypeConfigBase { export interface ObjectTypeConfigBase extends TypeConfigBase { readonly fields: ReadonlyArray; readonly astNode?: ObjectTypeDefinitionNode; + readonly moduleSpecification?: TypeModuleSpecificationConfig; } export interface RootEntityTypeConfig extends ObjectTypeConfigBase { diff --git a/src/model/create-model.ts b/src/model/create-model.ts index a59e21a7..a0a1fc75 100644 --- a/src/model/create-model.ts +++ b/src/model/create-model.ts @@ -55,6 +55,10 @@ import { INVERSE_OF_ARG, KEY_FIELD_ARG, KEY_FIELD_DIRECTIVE, + MODULES_ALL_ARG, + MODULES_DIRECTIVE, + MODULES_IN_ARG, + MODULES_INCLUDE_ALL_FIELDS_ARG, NAMESPACE_DIRECTIVE, NAMESPACE_NAME_ARG, NAMESPACE_SEPARATOR, @@ -106,22 +110,32 @@ import { TypeKind, } from './config'; import { BillingConfig } from './config/billing'; +import { ModuleConfig } from './config/module'; +import { + FieldModuleSpecificationConfig, + TypeModuleSpecificationConfig, +} from './config/module-specification'; import { Model } from './implementation'; import { OrderDirection } from './implementation/order'; import { parseBillingConfigs } from './parse-billing'; import { parseI18nConfigs } from './parse-i18n'; +import { parseModuleConfigs } from './parse-modules'; import { parseTTLConfigs } from './parse-ttl'; import { ValidationContext, ValidationMessage } from './validation'; -export function createModel(parsedProject: ParsedProject, options?: ModelOptions): Model { +export function createModel(parsedProject: ParsedProject, options: ModelOptions = {}): Model { const validationContext = new ValidationContext(); return new Model({ - types: createTypeInputs(parsedProject, validationContext, options ?? {}), + types: createTypeInputs(parsedProject, validationContext, options), permissionProfiles: extractPermissionProfiles(parsedProject), i18n: extractI18n(parsedProject), - validationMessages: validationContext.validationMessages, billing: extractBilling(parsedProject), timeToLiveConfigs: extractTimeToLive(parsedProject), + modules: extractModules(parsedProject, options, validationContext), + + // access this after the other function calls so we have collected all messages + // (it's currently passed by reference but let's not rely on that) + validationMessages: validationContext.validationMessages, options, }); } @@ -220,6 +234,7 @@ function createObjectTypeInput( fields: (definition.fields || []).map((field) => createFieldInput(field, context, options)), namespacePath: getNamespacePath(definition, schemaPart.namespacePath), flexSearchLanguage: getDefaultLanguage(definition, context), + moduleSpecification: getTypeModuleSpecification(definition, context, options), }; const businessObjectDirective = findDirectiveWithName(definition, BUSINESS_OBJECT_DIRECTIVE); @@ -660,6 +675,7 @@ function createFieldInput( accessFieldDirectiveASTNode, isHidden: !!hiddenDirectiveASTNode, isHiddenASTNode: hiddenDirectiveASTNode, + moduleSpecification: getFieldModuleSpecification(fieldNode, context, options), }; } @@ -1174,6 +1190,19 @@ function extractTimeToLive(parsedProject: ParsedProject): TimeToLiveConfig[] { .reduce((previousValue, currentValue) => previousValue.concat(currentValue), []); } +function extractModules( + parsedProject: ParsedProject, + options: ModelOptions, + validationContext: ValidationContext, +): ReadonlyArray { + const objectSchemaParts = parsedProject.sources.filter( + (parsedSource) => parsedSource.kind === ParsedProjectSourceBaseKind.OBJECT, + ) as ReadonlyArray; + return objectSchemaParts + .map((source) => parseModuleConfigs(source, options, validationContext)) + .reduce((previousValue, currentValue) => previousValue.concat(currentValue), []); +} + // fake input type for index mapping const indexDefinitionInputObjectType: GraphQLInputObjectType = new GraphQLInputObjectType({ fields: { @@ -1197,3 +1226,100 @@ const flexSearchOrderInputObjectType: GraphQLInputObjectType = new GraphQLInputO }, }, }); + +function getCombinedModuleSpecification( + definition: ObjectTypeDefinitionNode | FieldDefinitionNode, + context: ValidationContext, + options: ModelOptions, +): (TypeModuleSpecificationConfig & FieldModuleSpecificationConfig) | undefined { + const astNode = findDirectiveWithName(definition, MODULES_DIRECTIVE); + if (!astNode) { + return undefined; + } + + if (!options.withModuleDefinitions) { + context.addMessage( + ValidationMessage.error( + `Module specifications are not supported in this context.`, + astNode, + ), + ); + return undefined; + } + + const inAstNode = astNode.arguments?.find((a) => a.name.value === MODULES_IN_ARG); + let allAstNode = astNode.arguments?.find((a) => a.name.value === MODULES_ALL_ARG); + let includeAllFieldsAstNode = astNode.arguments?.find( + (a) => a.name.value === MODULES_INCLUDE_ALL_FIELDS_ARG, + ); + + // graphql allows you to omit the [] on lists... + const inNodes = + inAstNode?.value.kind === Kind.STRING + ? [inAstNode.value] + : inAstNode?.value.kind === Kind.LIST + ? inAstNode.value.values + : undefined; + + const config: TypeModuleSpecificationConfig & FieldModuleSpecificationConfig = { + astNode, + inAstNode, + allAstNode, + includeAllFieldsAstNode, + + in: inNodes?.map((astNode) => ({ + astNode, + expression: astNode.kind === Kind.STRING ? astNode.value : '', + })), + all: allAstNode?.value?.kind === Kind.BOOLEAN ? allAstNode.value.value : false, + includeAllFields: + includeAllFieldsAstNode?.value?.kind === Kind.BOOLEAN + ? includeAllFieldsAstNode.value.value + : false, + }; + return config; +} + +function getTypeModuleSpecification( + definition: ObjectTypeDefinitionNode, + context: ValidationContext, + options: ModelOptions, +): TypeModuleSpecificationConfig | undefined { + const config = getCombinedModuleSpecification(definition, context, options); + if (!config) { + return config; + } + + if (config.allAstNode) { + context.addMessage( + ValidationMessage.error( + `"${MODULES_ALL_ARG}" can only be specified on field declarations.`, + config.allAstNode, + ), + ); + } + + return config; +} + +function getFieldModuleSpecification( + definition: FieldDefinitionNode, + context: ValidationContext, + options: ModelOptions, +): FieldModuleSpecificationConfig | undefined { + const config = getCombinedModuleSpecification(definition, context, options); + if (!config) { + return config; + } + + if (config.includeAllFieldsAstNode) { + context.addMessage( + ValidationMessage.error( + `"${MODULES_INCLUDE_ALL_FIELDS_ARG}" can only be specified on type declarations.`, + config.includeAllFieldsAstNode, + ), + ); + } + + return config; +} diff --git a/src/model/implementation/collect-path.ts b/src/model/implementation/collect-path.ts index f028035d..9fba0202 100644 --- a/src/model/implementation/collect-path.ts +++ b/src/model/implementation/collect-path.ts @@ -3,8 +3,8 @@ import memorize from 'memorize-decorator'; import { QueryNode, VariableQueryNode } from '../../query-tree'; import { flatMap } from '../../utils/utils'; import { CollectFieldConfig } from '../config'; -import { locationWithinStringArgument, ValidationContext, ValidationMessage } from '../validation'; import { Field } from './field'; +import { locationWithinStringArgument, ValidationContext, ValidationMessage } from '../validation'; import { Multiplicity, RelationSide } from './relation'; import { RootEntityType } from './root-entity-type'; import { ObjectType, Type } from './type'; diff --git a/src/model/implementation/field-module-specification.ts b/src/model/implementation/field-module-specification.ts new file mode 100644 index 00000000..61caf390 --- /dev/null +++ b/src/model/implementation/field-module-specification.ts @@ -0,0 +1,48 @@ +import { Model } from '.'; +import { MODULES_ALL_ARG, MODULES_IN_ARG } from '../../schema/constants'; +import { FieldModuleSpecificationConfig } from '../config/module-specification'; +import { ValidationMessage } from '../validation'; +import { ValidationContext } from '../validation/validation-context'; +import { ModuleSpecification } from './module-specification'; + +export class FieldModuleSpecification extends ModuleSpecification { + readonly all: boolean; + + constructor(private readonly config: FieldModuleSpecificationConfig, model: Model) { + super(config, model); + this.all = config.all; + } + + validate(context: ValidationContext): void { + super.validate(context); + + if (this.all && this.clauses) { + context.addMessage( + ValidationMessage.error( + `"${MODULES_ALL_ARG}" and "${MODULES_IN_ARG}" cannot be combined.`, + this.config.allAstNode, + ), + ); + } + + if (!this.all && !this.clauses) { + context.addMessage( + ValidationMessage.error( + `Either "${MODULES_ALL_ARG}" or "${MODULES_IN_ARG}" needs to be specified.`, + this.astNode, + ), + ); + } + } + + /** + * Checks if a field / type with this module specification is included in a model where the given modules are selected + */ + includedIn(selectedModules: ReadonlyArray) { + if (this.all) { + return true; + } + + return super.includedIn(selectedModules); + } +} diff --git a/src/model/implementation/field.ts b/src/model/implementation/field.ts index c5279a74..e7ffd09f 100644 --- a/src/model/implementation/field.ts +++ b/src/model/implementation/field.ts @@ -7,6 +7,9 @@ import { COLLECT_DIRECTIVE, FLEX_SEARCH_CASE_SENSITIVE_ARGUMENT, FLEX_SEARCH_INCLUDED_IN_SEARCH_ARGUMENT, + MODULES_ALL_ARG, + MODULES_DIRECTIVE, + MODULES_INCLUDE_ALL_FIELDS_ARG, PARENT_DIRECTIVE, REFERENCE_DIRECTIVE, RELATION_DIRECTIVE, @@ -42,6 +45,7 @@ import { Relation, RelationSide } from './relation'; import { RolesSpecifier } from './roles-specifier'; import { InvalidType, ObjectType, Type } from './type'; import { ValueObjectType } from './value-object-type'; +import { FieldModuleSpecification } from './field-module-specification'; export interface SystemFieldConfig extends FieldConfig { readonly isSystemField?: boolean; @@ -82,6 +86,8 @@ export class Field implements ModelComponent { */ readonly isSystemField: boolean; + readonly moduleSpecification: FieldModuleSpecification | undefined; + constructor( private readonly input: SystemFieldConfig, public readonly declaringType: ObjectType, @@ -112,6 +118,12 @@ export class Field implements ModelComponent { this.isSystemField = input.isSystemField || false; this.isAccessField = input.isAccessField ?? false; this.isHidden = !!input.isHidden; + if (input.moduleSpecification) { + this.moduleSpecification = new FieldModuleSpecification( + input.moduleSpecification, + this.model, + ); + } } /** @@ -326,6 +338,7 @@ export class Field implements ModelComponent { this.validateRootField(context); this.validateFlexSearch(context); this.validateAccessField(context); + this.validateModuleSpecification(context); } private validateName(context: ValidationContext) { @@ -1860,6 +1873,35 @@ export class Field implements ModelComponent { } } + private validateModuleSpecification(context: ValidationContext) { + if (!this.moduleSpecification) { + if ( + !this.isSystemField && + this.declaringType.moduleSpecification && + !this.declaringType.moduleSpecification.includeAllFields + ) { + context.addMessage( + ValidationMessage.error( + `Missing module specification. Either add @${MODULES_DIRECTIVE} on field "${this.name}", or specify @${MODULES_DIRECTIVE}(${MODULES_INCLUDE_ALL_FIELDS_ARG}: true) on type "${this.declaringType.name}".`, + this.astNode, + ), + ); + } + return; + } + + if (this.declaringType.moduleSpecification?.includeAllFields) { + context.addMessage( + ValidationMessage.error( + `@${MODULES_DIRECTIVE} cannot be specified here because @${MODULES_DIRECTIVE}(${MODULES_INCLUDE_ALL_FIELDS_ARG}: true) is specified on type "${this.declaringType.name}", and therefore @${MODULES_DIRECTIVE}(${MODULES_ALL_ARG}: true) is implicitly configured for all its fields.`, + this.astNode, + ), + ); + } + + this.moduleSpecification.validate(context); + } + get isFlexSearchIndexed(): boolean { // @key-annotated fields are automatically flex-search indexed return ( diff --git a/src/model/implementation/model.ts b/src/model/implementation/model.ts index f926b89b..2923994a 100644 --- a/src/model/implementation/model.ts +++ b/src/model/implementation/model.ts @@ -20,6 +20,7 @@ import { ScalarType } from './scalar-type'; import { TimeToLiveType } from './time-to-live'; import { createType, InvalidType, ObjectType, Type } from './type'; import { ValueObjectType } from './value-object-type'; +import { Module } from './module'; export class Model implements ModelComponent { private readonly typeMap: ReadonlyMap; @@ -37,6 +38,7 @@ export class Model implements ModelComponent { readonly modelValidationOptions?: ModelOptions; readonly options?: ModelOptions; readonly timeToLiveTypes: ReadonlyArray; + readonly modules: ReadonlyArray; constructor(private input: ModelConfig) { this.builtInTypes = createBuiltInTypes(this); @@ -62,9 +64,16 @@ export class Model implements ModelComponent { this.timeToLiveTypes = input.timeToLiveConfigs ? input.timeToLiveConfigs.map((ttlConfig) => new TimeToLiveType(ttlConfig, this)) : []; + this.modules = input.modules.map((m) => new Module(m)); } validate(context = new ValidationContext()): ValidationResult { + if (this.input.validationMessages) { + for (const message of this.input.validationMessages) { + context.addMessage(message); + } + } + this.validateDuplicateTypes(context); this.i18n.validate(context); @@ -85,10 +94,9 @@ export class Model implements ModelComponent { permissionProfile.validate(context); } - return new ValidationResult([ - ...(this.input.validationMessages || []), - ...context.validationMessages, - ]); + this.validateDuplicateModules(context); + + return context.asResult(); } private validateDuplicateTypes(context: ValidationContext) { @@ -122,6 +130,22 @@ export class Model implements ModelComponent { } } + private validateDuplicateModules(context: ValidationContext) { + const duplicateModules = objectValues(groupBy(this.modules, (type) => type.name)).filter( + (types) => types.length > 1, + ); + for (const modules of duplicateModules) { + for (const module of modules) { + context.addMessage( + ValidationMessage.error( + `Duplicate module declaration: "${module.name}".`, + module.loc, + ), + ); + } + } + } + getType(name: string): Type | undefined { return this.typeMap.get(name); } diff --git a/src/model/implementation/module-specification.ts b/src/model/implementation/module-specification.ts new file mode 100644 index 00000000..66bed1b4 --- /dev/null +++ b/src/model/implementation/module-specification.ts @@ -0,0 +1,140 @@ +import { ASTNode } from 'graphql'; +import { + BaseModuleSpecificationConfig, + ModuleSpecificationClauseConfig, +} from '../config/module-specification'; +import { ModelComponent, ValidationContext } from '../validation/validation-context'; +import { Model } from '.'; +import { parseModuleSpecificationExpression } from '../utils/modules'; +import { ValidationMessage, locationWithinStringArgument } from '../validation'; +import memorize from 'memorize-decorator'; + +export abstract class ModuleSpecification implements ModelComponent { + readonly clauses: ReadonlyArray | null; + readonly astNode: ASTNode | undefined; + readonly inAstNode: ASTNode | undefined; + + constructor(config: BaseModuleSpecificationConfig, protected readonly model: Model) { + this.clauses = config.in + ? config.in.map((clauseConfig) => new ModuleSpecificationClause(clauseConfig, model)) + : null; + this.astNode = config.astNode; + this.inAstNode = config.inAstNode; + } + + validate(context: ValidationContext): void { + if (this.clauses) { + for (const item of this.clauses) { + item.validate(context); + } + } + } + + /** + * Checks if a field / type with this module specification is included in a model where the given modules are selected + */ + includedIn(selectedModules: ReadonlyArray) { + if (!this.clauses) { + return false; + } + + return this.clauses.some((clause) => clause.includedIn(selectedModules)); + } +} + +export class ModuleSpecificationClause implements ModelComponent { + readonly expression: string; + + constructor( + private readonly config: ModuleSpecificationClauseConfig, + private readonly model: Model, + ) { + this.expression = config.expression; + } + + @memorize() + get andCombinedModules(): ReadonlyArray { + return this.parse(new ValidationContext()); + } + + validate(context: ValidationContext): void { + this.parse(context); + } + + /** + * Checks all the modules in this clause are present in the given selected modules + */ + includedIn(selectedModules: ReadonlyArray) { + if (!this.andCombinedModules.length) { + // this is an error state + return false; + } + + const selectedModuleSet = new Set(selectedModules); + for (const module of this.andCombinedModules) { + if (!selectedModuleSet.has(module)) { + return false; + } + } + return true; + } + + private parse(context: ValidationContext): ReadonlyArray { + if (!this.expression) { + context.addMessage( + ValidationMessage.error(`Module specifier cannot be empty.`, this.config.astNode), + ); + return []; + } + + const result = parseModuleSpecificationExpression(this.expression); + if (result.error) { + context.addMessage( + ValidationMessage.error( + result.error.message, + this.config.astNode + ? locationWithinStringArgument( + this.config.astNode, + result.error.offset, + + // result.error.offset can be on the "EOL characeter". + // Use at least a width of 1, so we report this on the closing " character + Math.max(this.expression.length - result.error.offset, 1), + ) + : undefined, + ), + ); + return []; + } + + if (!result.andCombinedIdentifiers) { + // should not be possible + throw new Error(`Did not expect andCombinedIdentifiers to be empty`); + } + + let hasInvalidModules = false; + const declaredModules = new Set(this.model.modules.map((m) => m.name)); + for (const identifier of result.andCombinedIdentifiers) { + if (!declaredModules.has(identifier.name)) { + hasInvalidModules = true; + context.addMessage( + ValidationMessage.error( + `Module "${identifier.name}" does not exist.`, + this.config.astNode + ? locationWithinStringArgument( + this.config.astNode, + identifier.offset, + this.expression.length - identifier.name.length, + ) + : undefined, + ), + ); + } + } + if (hasInvalidModules) { + return []; + } + + return result.andCombinedIdentifiers.map((i) => i.name); + } +} diff --git a/src/model/implementation/module.ts b/src/model/implementation/module.ts new file mode 100644 index 00000000..85cf7895 --- /dev/null +++ b/src/model/implementation/module.ts @@ -0,0 +1,33 @@ +import { ModuleConfig } from '../config/module'; +import { + ALLOWD_MODULE_IDENTIFIER_CHARACTERS_DESCRIPTION, + MODULE_IDENTIFIER_PATTERN, +} from '../utils/modules'; +import { MessageLocation, ValidationMessage } from '../validation'; +import { ModelComponent, ValidationContext } from '../validation/validation-context'; + +/** + * A module that can be assigned to types and fields + */ +export class Module implements ModelComponent { + readonly name: string; + readonly loc: MessageLocation | undefined; + + constructor(private readonly config: ModuleConfig) { + this.name = config.name; + this.loc = config.loc; + } + + validate(context: ValidationContext) { + if (!this.name) { + context.addMessage(ValidationMessage.error(`Module names cannot be empty`, this.loc)); + } else if (!this.name.match(MODULE_IDENTIFIER_PATTERN)) { + context.addMessage( + ValidationMessage.error( + `Module names can only consist of ${ALLOWD_MODULE_IDENTIFIER_CHARACTERS_DESCRIPTION}`, + this.loc, + ), + ); + } + } +} diff --git a/src/model/implementation/object-type-base.ts b/src/model/implementation/object-type-base.ts index b09b763a..9f25290a 100644 --- a/src/model/implementation/object-type-base.ts +++ b/src/model/implementation/object-type-base.ts @@ -6,6 +6,7 @@ import { Field, SystemFieldConfig } from './field'; import { Model } from './model'; import { ObjectType } from './type'; import { TypeBase } from './type-base'; +import { TypeModuleSpecification } from './type-module-specification'; export abstract class ObjectTypeBase extends TypeBase { readonly fields: ReadonlyArray; @@ -13,6 +14,7 @@ export abstract class ObjectTypeBase extends TypeBase { readonly systemFieldOverrides: ReadonlyMap; readonly systemFields: ReadonlyMap; readonly systemFieldConfigs: ReadonlyMap; + readonly moduleSpecification?: TypeModuleSpecification; protected constructor( input: ObjectTypeConfig, @@ -56,6 +58,13 @@ export abstract class ObjectTypeBase extends TypeBase { this.fields = [...systemFields, ...customFields]; this.fieldMap = new Map(this.fields.map((field): [string, Field] => [field.name, field])); + + if (input.moduleSpecification) { + this.moduleSpecification = new TypeModuleSpecification( + input.moduleSpecification, + this.model, + ); + } } validate(context: ValidationContext) { @@ -76,6 +85,8 @@ export abstract class ObjectTypeBase extends TypeBase { for (const field of this.fields) { field.validate(context); } + + this.validateModuleSpecification(context); } private validateSystemFieldOverrides(context: ValidationContext): void { @@ -140,6 +151,12 @@ export abstract class ObjectTypeBase extends TypeBase { } } + private validateModuleSpecification(context: ValidationContext) { + if (this.moduleSpecification) { + this.moduleSpecification.validate(context); + } + } + getField(name: string): Field | undefined { return this.fieldMap.get(name); } diff --git a/src/model/implementation/root-entity-type.ts b/src/model/implementation/root-entity-type.ts index f46b141b..551d6d78 100644 --- a/src/model/implementation/root-entity-type.ts +++ b/src/model/implementation/root-entity-type.ts @@ -7,6 +7,7 @@ import { FLEX_SEARCH_FULLTEXT_INDEXED_DIRECTIVE, FLEX_SEARCH_INDEXED_DIRECTIVE, ID_FIELD, + MODULES_DIRECTIVE, SCALAR_INT, SCALAR_STRING, } from '../../schema/constants'; @@ -18,7 +19,7 @@ import { FlexSearchPrimarySortClauseConfig, PermissionsConfig, RootEntityTypeConfig, - TypeKind + TypeKind, } from '../config'; import { ValidationContext, ValidationMessage } from '../validation'; import { Field, SystemFieldConfig } from './field'; @@ -230,6 +231,7 @@ export class RootEntityType extends ObjectTypeBase { this.validatePermissions(context); this.validateIndices(context); this.validateFlexSearch(context); + this.validateRootEntityModuleSpecification(context); } private validateKeyField(context: ValidationContext) { @@ -472,6 +474,18 @@ export class RootEntityType extends ObjectTypeBase { } } + private validateRootEntityModuleSpecification(context: ValidationContext) { + const withModuleDefinitions = this.model.options?.withModuleDefinitions ?? false; + if (withModuleDefinitions && !this.moduleSpecification) { + context.addMessage( + ValidationMessage.error( + `Missing module specification. Add ${MODULES_DIRECTIVE}(in: ...) to specify the modules of this root entity type.`, + this.nameASTNode, + ), + ); + } + } + @memorize() get billingEntityConfig() { return this.model.billingEntityTypes.find((value) => value.rootEntityType === this); diff --git a/src/model/implementation/type-module-specification.ts b/src/model/implementation/type-module-specification.ts new file mode 100644 index 00000000..6fd4b2ae --- /dev/null +++ b/src/model/implementation/type-module-specification.ts @@ -0,0 +1,28 @@ +import { MODULES_DIRECTIVE, MODULES_IN_ARG } from '../../schema/constants'; +import { TypeModuleSpecificationConfig } from '../config/module-specification'; +import { ValidationMessage } from '../validation'; +import { ValidationContext } from '../validation/validation-context'; +import { Model } from './model'; +import { ModuleSpecification } from './module-specification'; + +export class TypeModuleSpecification extends ModuleSpecification { + readonly includeAllFields: boolean; + + constructor(private readonly config: TypeModuleSpecificationConfig, model: Model) { + super(config, model); + this.includeAllFields = config.includeAllFields; + } + + validate(context: ValidationContext): void { + super.validate(context); + + if (!this.clauses) { + context.addMessage( + ValidationMessage.error( + `@${MODULES_DIRECTIVE}(${MODULES_IN_ARG}: ...) needs to be specified.`, + this.astNode, + ), + ); + } + } +} diff --git a/src/model/parse-modules.ts b/src/model/parse-modules.ts new file mode 100644 index 00000000..39be5617 --- /dev/null +++ b/src/model/parse-modules.ts @@ -0,0 +1,36 @@ +import { ModelOptions } from '../config/interfaces'; +import { ParsedObjectProjectSource } from '../config/parsed-project'; +import { ModuleConfig } from './config/module'; +import { ValidationContext, ValidationMessage } from './validation'; + +export function parseModuleConfigs( + source: ParsedObjectProjectSource, + options: ModelOptions, + validationContext: ValidationContext, +): ReadonlyArray { + if (!source.object || !source.object.modules || !Array.isArray(source.object.modules)) { + return []; + } + + // only allow module definitions if it's enabled on the project + // do this validation here because it's the only place where we can report one error per + // source file instead of complaining about each individual module + const withModuleDefinitions = options.withModuleDefinitions ?? false; + if (!withModuleDefinitions) { + validationContext.addMessage( + ValidationMessage.error( + `Module declarations are not supported in this context.`, + source.pathLocationMap[`/modules`], + ), + ); + return []; + } + + const moduleConfigs = source.object.modules as ReadonlyArray; + return moduleConfigs.map( + (name, index): ModuleConfig => ({ + name, + loc: source.pathLocationMap[`/modules/${index}`], + }), + ); +} diff --git a/src/model/utils/modules.ts b/src/model/utils/modules.ts new file mode 100644 index 00000000..19c22d65 --- /dev/null +++ b/src/model/utils/modules.ts @@ -0,0 +1,121 @@ +enum ParserState { + EXPECT_IDENTIFIER, + PARSING_IDENTIFIER, + EXPECT_AMPERSAND_OR_END, + EXPECT_SECOND_AMPERSAND, +} + +const IDENTIFIER_CHAR_PATTERN = /[a-zA-Z_0-9_\.-]/; +export const MODULE_IDENTIFIER_PATTERN = /^[a-zA-Z_0-9_\.-]+$/; +export const ALLOWD_MODULE_IDENTIFIER_CHARACTERS_DESCRIPTION = 'a-z, A-Z, 0-9, or _ . -'; + +const EOF = Symbol('EOF'); + +export interface ParseModuleSpecificationExpressionResult { + readonly error?: ParseModuleSpecificationExpressionError; + readonly andCombinedIdentifiers?: ReadonlyArray; +} + +export interface ParseModuleSpecificationExpressionError { + readonly message: string; + readonly offset: number; +} + +export interface ParseModuleSpecificationExpressionResultIdentifier { + readonly name: string; + readonly offset: number; +} + +export function parseModuleSpecificationExpression( + expression: string, +): ParseModuleSpecificationExpressionResult { + let state = ParserState.EXPECT_IDENTIFIER; + let currentIdentifer = ''; + const andCombinedIdentifiers: ParseModuleSpecificationExpressionResultIdentifier[] = []; + // <= expression.length so we have one extra iteration for EOF to finish up + for (let offset = 0; offset <= expression.length; offset++) { + const char = offset < expression.length ? expression[offset] : EOF; + const isEOF = char === EOF; + const isIdentifierChar = char !== EOF && !!char.match(IDENTIFIER_CHAR_PATTERN); + const isWhitespace = char !== EOF && char.match(/\s/); + switch (state) { + case ParserState.EXPECT_IDENTIFIER: + if (isIdentifierChar) { + currentIdentifer = char; + state = ParserState.PARSING_IDENTIFIER; + } else if (isWhitespace) { + // do nothing + } else if (isEOF) { + return { + error: { + offset, + message: `Expected identifier.`, + }, + }; + } else { + return { + error: { + offset, + message: `Expected identifier, but got "${char}".`, + }, + }; + } + break; + + case ParserState.PARSING_IDENTIFIER: + if (isIdentifierChar) { + currentIdentifer += char; + } else { + // done parsing identifier + andCombinedIdentifiers.push({ + name: currentIdentifer, + offset: offset - currentIdentifer.length, + }); + if (char === '&') { + state = ParserState.EXPECT_SECOND_AMPERSAND; + } else if (isWhitespace) { + state = ParserState.EXPECT_AMPERSAND_OR_END; + } else if (isEOF) { + // do nothing + } else { + return { + error: { + offset, + message: `Expected identifier or "&&", but got "${char}".`, + }, + }; + } + } + break; + + case ParserState.EXPECT_SECOND_AMPERSAND: + if (char === '&') { + state = ParserState.EXPECT_IDENTIFIER; + } else { + // user probably didn't know that you need to double the & + return { + error: { + offset: offset - 1, + message: `Expected "&&", but only got single "&".`, + }, + }; + } + break; + case ParserState.EXPECT_AMPERSAND_OR_END: + if (char === '&') { + state = ParserState.EXPECT_SECOND_AMPERSAND; + } else if (isEOF) { + // do nothing + } else { + return { + error: { + offset, + message: `Expected "&&", but got "${char}".`, + }, + }; + } + } + } + + return { andCombinedIdentifiers }; +} diff --git a/src/model/validation/message.ts b/src/model/validation/message.ts index 0d396e83..9d68824d 100644 --- a/src/model/validation/message.ts +++ b/src/model/validation/message.ts @@ -6,6 +6,7 @@ export enum Severity { ERROR = 'ERROR', WARNING = 'WARNING', INFO = 'INFO', + COMPATIBILITY_ISSUE = 'COMPATIBILITY_ISSUE', } export class SourcePosition { @@ -147,6 +148,8 @@ function severityToString(severity: Severity) { return 'Info'; case Severity.WARNING: return 'Warning'; + case Severity.COMPATIBILITY_ISSUE: + return 'Compatibility issue'; } } diff --git a/src/project/project.ts b/src/project/project.ts index 68d77354..f9602750 100644 --- a/src/project/project.ts +++ b/src/project/project.ts @@ -146,6 +146,16 @@ export class Project { return getModel(this); } + /** + * Checks if this project is compatible with the modules defined and selected in the given model + * + * @param modelWithModuleDefinitions a model created by getModel() on a project where withModuleDefinitions was set to true + * @return a ValidationResult, with messages of severity COMPATIBILITY_ISSUE if there are compatibility issues, or none if the project is compatible + */ + checkModuleCompatibility(modelWithModuleDefinitions: Model): ValidationResult { + throw new Error('not implemented yet'); + } + /** * Creates an executable GraphQLSchema that uses the given DatabaseAdapter to execute queries * diff --git a/src/schema/constants.ts b/src/schema/constants.ts index 5a06ae8f..314c878c 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -39,6 +39,11 @@ export const INVERSE_OF_ARG = 'inverseOf'; export const ON_DELETE_ARG = 'onDelete'; export const KEY_FIELD_ARG = 'keyField'; +export const MODULES_DIRECTIVE = 'modules'; +export const MODULES_IN_ARG = 'in'; +export const MODULES_ALL_ARG = 'all'; +export const MODULES_INCLUDE_ALL_FIELDS_ARG = 'includeAllFields'; + export const QUERY_TYPE = 'Query'; export const MUTATION_TYPE = 'Mutation'; export const QUERY_META_TYPE = '_QueryMeta'; diff --git a/src/schema/graphql-base.ts b/src/schema/graphql-base.ts index 54e34ceb..fd7718fc 100644 --- a/src/schema/graphql-base.ts +++ b/src/schema/graphql-base.ts @@ -247,6 +247,27 @@ export const DIRECTIVES: DocumentNode = gql` Marks this field as hidden in the meta schema so it will not be listed in generic UIs """ directive @hidden on FIELD_DEFINITION + + """ + Assigns this type or field to modules + """ + directive @modules( + """ + A list of modules this type or field should be part of. + + Can be an expression like module1 && module2. + + Can include modules that are not listed in the declaring type. + """ + in: [String!] + + "Specifies that this field should be included in all modules that include the declaring type." + all: Boolean + + + "Specifies that all fields in this type should be included in all modules declared on this type." + includeAllFields: Boolean + ) on OBJECT | FIELD_DEFINITION `; export const CORE_SCALARS: DocumentNode = gql` diff --git a/src/schema/preparation/source-validation-modules/schema/schema.json b/src/schema/preparation/source-validation-modules/schema/schema.json index 8eb22a31..db5581b2 100644 --- a/src/schema/preparation/source-validation-modules/schema/schema.json +++ b/src/schema/preparation/source-validation-modules/schema/schema.json @@ -100,6 +100,12 @@ }, "required": ["typeName", "dateField", "expireAfterDays"] } + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } } }, "definitions": { diff --git a/src/schema/preparation/source-validation-modules/schema/validate-schema.js b/src/schema/preparation/source-validation-modules/schema/validate-schema.js index aca46917..c76813de 100644 --- a/src/schema/preparation/source-validation-modules/schema/validate-schema.js +++ b/src/schema/preparation/source-validation-modules/schema/validate-schema.js @@ -1 +1 @@ -"use strict";module.exports = validate20;module.exports.default = validate20;const schema22 = {"$schema":"http://json-schema.org/draft-07/schema#","description":"Sidecar file for schema definitions","type":"object","minProperties":1,"additionalProperties":false,"properties":{"permissionProfiles":{"type":"object","additionalProperties":false,"patternProperties":{"^[a-zA-Z0-9]+$":{"$ref":"#/definitions/PermissionProfile"}}},"i18n":{"type":"object","additionalProperties":false,"patternProperties":{"^[a-zA-Z0-9_-]+$":{"$ref":"#/definitions/NamespaceLocalization"}}},"billing":{"type":"object","properties":{"billingEntities":{"type":"array","items":{"type":"object","properties":{"typeName":{"type":"string","pattern":"^[a-zA-Z0-9_-]+$"},"keyFieldName":{"type":"string","pattern":"^[a-zA-Z0-9_-]+$"},"quantityFieldName":{"type":"string"},"category":{"type":"string"},"categoryMapping":{"type":"object","properties":{"fieldName":{"type":"string"},"defaultValue":{"type":"string","pattern":"^[a-zA-Z0-9_-]+$"},"values":{"type":"object","additionalProperties":{"type":"string"}}},"additionalProperties":false,"required":["fieldName","defaultValue","values"]}},"required":["typeName"],"additionalProperties":false}}},"additionalProperties":false},"timeToLive":{"type":"array","items":{"type":"object","properties":{"typeName:":{"type":"string","pattern":"^[a-zA-Z0-9_-]+$"},"dateField:":{"type":"string","pattern":"^([a-zA-Z0-9_-]|\\.)+$"},"expireAfterDays":{"type":"integer","minimum":1},"cascadeFields:":{"type":"array","items":{"type":"string","pattern":"^([a-zA-Z0-9_-]|\\.)+$"}}},"required":["typeName","dateField","expireAfterDays"]}}},"definitions":{"PermissionProfile":{"type":"object","additionalProperties":false,"properties":{"permissions":{"type":"array","items":{"$ref":"#/definitions/Permission"}}}},"Permission":{"type":"object","required":["roles","access"],"additionalProperties":false,"properties":{"roles":{"type":"array","minItems":1,"items":{"type":"string","pattern":".+"}},"access":{"oneOf":[{"type":"string","enum":["read","readWrite","create","update","delete"]},{"type":"array","items":{"type":"string","enum":["read","readWrite","create","update","delete"]},"minItems":1}]},"restrictToAccessGroups":{"type":"array","minItems":1,"items":{"type":"string","pattern":".+"}},"restrictions":{"type":"array","items":{"$ref":"#/definitions/PermissionRestriction"}}}},"PermissionRestriction":{"type":"object","required":["field"],"properties":{"field":{"type":"string"}},"oneOf":[{"$ref":"#/definitions/PermissionRestrictionWithValue"},{"$ref":"#/definitions/PermissionRestrictionWithValueTemplate"},{"$ref":"#/definitions/PermissionRestrictionWithClaim"}]},"PermissionRestrictionWithValue":{"type":"object","required":["value"],"properties":{"value":{}}},"PermissionRestrictionWithValueTemplate":{"type":"object","required":["valueTemplate"],"properties":{"valueTemplate":{"type":"string"}}},"PermissionRestrictionWithClaim":{"type":"object","required":["claim"],"properties":{"claim":{"type":"string","minLength":1}}},"NamespaceLocalization":{"type":"object","additionalProperties":false,"properties":{"types":{"type":"object","patternProperties":{"^[a-zA-Z0-9_]+$":{"$ref":"#/definitions/TypeLocalization"}}},"fields":{"type":"object","patternProperties":{"^[a-zA-Z0-9_]+$":{"anyOf":[{"$ref":"#/definitions/FieldLocalization"},{"type":"string"}]}}}}},"TypeLocalization":{"type":"object","additionalProperties":false,"properties":{"fields":{"type":"object","patternProperties":{"^[a-zA-Z0-9_]+$":{"anyOf":[{"$ref":"#/definitions/FieldLocalization"},{"type":"string"}]}}},"values":{"type":"object","patternProperties":{"^[a-zA-Z0-9_]+$":{"anyOf":[{"$ref":"#/definitions/EnumValueLocalization"},{"type":"string"}]}}},"label":{"type":"string"},"labelPlural":{"type":"string"},"hint":{"type":"string"}}},"FieldLocalization":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string"},"hint":{"type":"string"}}},"EnumValueLocalization":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string"},"hint":{"type":"string"}}}}};const pattern0 = new RegExp("^[a-zA-Z0-9]+$", "u");const pattern4 = new RegExp("^[a-zA-Z0-9_-]+$", "u");const pattern14 = new RegExp("^([a-zA-Z0-9_-]|\\.)+$", "u");const schema23 = {"type":"object","additionalProperties":false,"properties":{"permissions":{"type":"array","items":{"$ref":"#/definitions/Permission"}}}};const schema24 = {"type":"object","required":["roles","access"],"additionalProperties":false,"properties":{"roles":{"type":"array","minItems":1,"items":{"type":"string","pattern":".+"}},"access":{"oneOf":[{"type":"string","enum":["read","readWrite","create","update","delete"]},{"type":"array","items":{"type":"string","enum":["read","readWrite","create","update","delete"]},"minItems":1}]},"restrictToAccessGroups":{"type":"array","minItems":1,"items":{"type":"string","pattern":".+"}},"restrictions":{"type":"array","items":{"$ref":"#/definitions/PermissionRestriction"}}}};const pattern2 = new RegExp(".+", "u");const schema25 = {"type":"object","required":["field"],"properties":{"field":{"type":"string"}},"oneOf":[{"$ref":"#/definitions/PermissionRestrictionWithValue"},{"$ref":"#/definitions/PermissionRestrictionWithValueTemplate"},{"$ref":"#/definitions/PermissionRestrictionWithClaim"}]};const schema26 = {"type":"object","required":["value"],"properties":{"value":{}}};const schema27 = {"type":"object","required":["valueTemplate"],"properties":{"valueTemplate":{"type":"string"}}};const schema28 = {"type":"object","required":["claim"],"properties":{"claim":{"type":"string","minLength":1}}};const func4 = require("ajv/dist/runtime/ucs2length").default;function validate23(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs1 = errors;let valid0 = false;let passing0 = null;const _errs2 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.value === undefined){const err0 = {instancePath,schemaPath:"#/definitions/PermissionRestrictionWithValue/required",keyword:"required",params:{missingProperty: "value"},message:"must have required property '"+"value"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}else {const err1 = {instancePath,schemaPath:"#/definitions/PermissionRestrictionWithValue/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}var _valid0 = _errs2 === errors;if(_valid0){valid0 = true;passing0 = 0;}const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.valueTemplate === undefined){const err2 = {instancePath,schemaPath:"#/definitions/PermissionRestrictionWithValueTemplate/required",keyword:"required",params:{missingProperty: "valueTemplate"},message:"must have required property '"+"valueTemplate"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.valueTemplate !== undefined){if(typeof data.valueTemplate !== "string"){const err3 = {instancePath:instancePath+"/valueTemplate",schemaPath:"#/definitions/PermissionRestrictionWithValueTemplate/properties/valueTemplate/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}}else {const err4 = {instancePath,schemaPath:"#/definitions/PermissionRestrictionWithValueTemplate/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}var _valid0 = _errs5 === errors;if(_valid0 && valid0){valid0 = false;passing0 = [passing0, 1];}else {if(_valid0){valid0 = true;passing0 = 1;}const _errs10 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.claim === undefined){const err5 = {instancePath,schemaPath:"#/definitions/PermissionRestrictionWithClaim/required",keyword:"required",params:{missingProperty: "claim"},message:"must have required property '"+"claim"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.claim !== undefined){let data1 = data.claim;if(typeof data1 === "string"){if(func4(data1) < 1){const err6 = {instancePath:instancePath+"/claim",schemaPath:"#/definitions/PermissionRestrictionWithClaim/properties/claim/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {const err7 = {instancePath:instancePath+"/claim",schemaPath:"#/definitions/PermissionRestrictionWithClaim/properties/claim/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}}else {const err8 = {instancePath,schemaPath:"#/definitions/PermissionRestrictionWithClaim/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}var _valid0 = _errs10 === errors;if(_valid0 && valid0){valid0 = false;passing0 = [passing0, 2];}else {if(_valid0){valid0 = true;passing0 = 2;}}}if(!valid0){const err9 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}else {errors = _errs1;if(vErrors !== null){if(_errs1){vErrors.length = _errs1;}else {vErrors = null;}}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.field === undefined){const err10 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "field"},message:"must have required property '"+"field"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data.field !== undefined){if(typeof data.field !== "string"){const err11 = {instancePath:instancePath+"/field",schemaPath:"#/properties/field/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}}else {const err12 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}validate23.errors = vErrors;return errors === 0;}function validate22(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(data && typeof data == "object" && !Array.isArray(data)){if(data.roles === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "roles"},message:"must have required property '"+"roles"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.access === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "access"},message:"must have required property '"+"access"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}for(const key0 in data){if(!((((key0 === "roles") || (key0 === "access")) || (key0 === "restrictToAccessGroups")) || (key0 === "restrictions"))){const err2 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.roles !== undefined){let data0 = data.roles;if(Array.isArray(data0)){if(data0.length < 1){const err3 = {instancePath:instancePath+"/roles",schemaPath:"#/properties/roles/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}const len0 = data0.length;for(let i0=0; i0=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}}if(data17["cascadeFields:"] !== undefined){let data21 = data17["cascadeFields:"];if(Array.isArray(data21)){const len2 = data21.length;for(let i2=0; i2=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}}if(data17["cascadeFields:"] !== undefined){let data21 = data17["cascadeFields:"];if(Array.isArray(data21)){const len2 = data21.length;for(let i2=0; i2