/** * Query and QueryBuilder implementation for the Model Query SDK. * Provides fluent, lazy evaluation of model queries. */ import type { AstNode, LangiumDocument, URI } from 'langium'; import { AstUtils } from 'langium'; import type { BoundedContext, BoundedContextRef, Classification, ContextMap, Domain, DomainMap, Model, NamespaceDeclaration, Relationship, Team, } from '../generated/ast.js'; import { isBoundedContext, isClassification, isContextMap, isDirectionalRelationship, isDomain, isDomainMap, isModel, isNamespaceDeclaration, isSymmetricRelationship, isTeam, isThisRef, isSupplier, isCustomer, } from '../generated/ast.js'; import { QualifiedNameProvider } from '../services/naming.js'; import type { DomainLangServices } from './bootstrap.js'; import { buildIndexes } from './indexes.js'; import { metadataAsMap, effectiveClassification, effectiveTeam, } from './resolution.js'; import { isDownstreamPattern, isUpstreamPattern, matchesPattern } from './patterns.js'; import type { BcQueryBuilder, DirectionalKind, ModelIndexes, Query, QueryBuilder, RelationshipView, } from './types.js'; /** * Shared QualifiedNameProvider instance. * QualifiedNameProvider is stateless (no constructor arguments, pure method), * so a single module-level instance is safe to reuse across all augmentation calls. */ const fqnProvider = new QualifiedNameProvider(); /** * Tracks which models have been augmented to avoid redundant augmentation. * Uses WeakSet to allow garbage collection of unused models. */ const augmentedModels = new WeakSet(); /** * Ensures a model is augmented with SDK properties. * Idempotent - safe to call multiple times. * * @param model - Model to ensure is augmented */ function ensureAugmented(model: Model): void { if (!augmentedModels.has(model)) { augmentModelInternal(model); augmentedModels.add(model); } } /** * Creates a Query instance from a Model node. * Zero-copy operation for already-linked AST (used in LSP/validation). * * Automatically augments the AST with resolved properties on first access. * * @param model - Root Model node * @returns Query interface for model traversal */ export function fromModel(model: Model): Query { ensureAugmented(model); return new QueryImpl(model); } /** * Creates a Query instance from a LangiumDocument. * Zero-copy operation for already-linked AST (used in LSP providers). * * Automatically augments the AST with resolved properties on first access. * * @param document - LangiumDocument containing a Model * @returns Query interface for model traversal */ export function fromDocument(document: LangiumDocument): Query { const model = document.parseResult.value; ensureAugmented(model); return new QueryImpl(model); } /** * Creates a Query instance from DomainLangServices and document URI. * Zero-copy operation for already-linked AST (used when only services available). * * Automatically augments the AST with resolved properties on first access. * * @param services - DomainLangServices instance * @param documentUri - URI of the document to query * @returns Query interface for model traversal * @throws Error if document not found or not a Model */ export function fromServices(services: DomainLangServices, documentUri: URI): Query { const document = services.shared.workspace.LangiumDocuments.getDocument(documentUri); if (!document) { throw new Error(`Document not found: ${documentUri.toString()}`); } const model = document.parseResult.value; if (!isModel(model)) { throw new Error(`Document root is not a Model: ${documentUri.toString()}`); } ensureAugmented(model); return new QueryImpl(model); } /** * Implementation of Query interface. * Lazily builds indexes on first access. */ class QueryImpl implements Query { private readonly model: Model; private readonly fqnProvider: QualifiedNameProvider; private indexes?: ModelIndexes; constructor(model: Model) { this.model = model; this.fqnProvider = fqnProvider; } /** * Lazily builds and caches indexes on first access. */ private getIndexes(): ModelIndexes { this.indexes ??= buildIndexes(this.model); return this.indexes; } domains(): QueryBuilder { // Use generator for lazy iteration per PRS requirement const model = this.model; function* domainIterator(): Generator { for (const node of AstUtils.streamAllContents(model)) { if (isDomain(node)) { yield node; } } } return new QueryBuilderImpl(domainIterator(), this.fqnProvider); } boundedContexts(): BcQueryBuilder { // Use generator for lazy iteration per PRS requirement const model = this.model; function* bcIterator(): Generator { for (const node of AstUtils.streamAllContents(model)) { if (isBoundedContext(node)) { yield node; } } } return new BcQueryBuilderImpl(bcIterator(), this.fqnProvider, this.getIndexes()); } teams(): QueryBuilder { // Use generator for lazy iteration const model = this.model; function* teamIterator(): Generator { for (const node of AstUtils.streamAllContents(model)) { if (isTeam(node)) { yield node; } } } return new QueryBuilderImpl(teamIterator(), this.fqnProvider); } classifications(): QueryBuilder { // Use generator for lazy iteration const model = this.model; function* classIterator(): Generator { for (const node of AstUtils.streamAllContents(model)) { if (isClassification(node)) { yield node; } } } return new QueryBuilderImpl(classIterator(), this.fqnProvider); } relationships(): QueryBuilder { // Use generator for lazy iteration - combines BC and ContextMap relationships return new QueryBuilderImpl(this.iterateRelationships(), this.fqnProvider); } /** @internal Generator for relationship iteration */ private *iterateRelationships(): Generator { yield* this.collectBoundedContextRelationships(); yield* this.collectContextMapRelationships(); } /** @internal Collects relationships from bounded contexts */ private *collectBoundedContextRelationships(): Generator { for (const node of AstUtils.streamAllContents(this.model)) { if (isBoundedContext(node)) { for (const rel of node.relationships) { const view = this.createRelationshipView(rel, node, 'BoundedContext'); if (view) { yield view; } } } } } /** @internal Collects relationships from context maps */ private *collectContextMapRelationships(): Generator { for (const node of AstUtils.streamAllContents(this.model)) { if (isContextMap(node)) { for (const rel of node.relationships) { const view = this.createRelationshipView(rel, undefined, 'ContextMap'); if (view) { yield view; } } } } } contextMaps(): QueryBuilder { const model = this.model; function* cmapIterator(): Generator { for (const node of AstUtils.streamAllContents(model)) { if (isContextMap(node)) { yield node; } } } return new QueryBuilderImpl(cmapIterator(), this.fqnProvider); } domainMaps(): QueryBuilder { const model = this.model; function* dmapIterator(): Generator { for (const node of AstUtils.streamAllContents(model)) { if (isDomainMap(node)) { yield node; } } } return new QueryBuilderImpl(dmapIterator(), this.fqnProvider); } namespaces(): QueryBuilder { const model = this.model; function* nsIterator(): Generator { for (const node of AstUtils.streamAllContents(model)) { if (isNamespaceDeclaration(node)) { yield node; } } } return new QueryBuilderImpl(nsIterator(), this.fqnProvider); } /** * Looks up an AST node by its fully-qualified name. * * Use a type guard (e.g. `isDomain`, `isBoundedContext`) to narrow the result. */ byFqn(fqn: string): AstNode | undefined { return this.getIndexes().byFqn.get(fqn); } domain(name: string): Domain | undefined { // Try FQN lookup first, guarded to ensure it is actually a Domain node const byFqn = this.byFqn(name); if (byFqn && isDomain(byFqn)) { return byFqn; } // Fallback to simple name lookup const nodes = this.getIndexes().byName.get(name) ?? []; return nodes.find(isDomain); } boundedContext(name: string): BoundedContext | undefined { // Try FQN lookup first, guarded to ensure it is actually a BoundedContext node const byFqn = this.byFqn(name); if (byFqn && isBoundedContext(byFqn)) { return byFqn; } // Fallback to simple name lookup const nodes = this.getIndexes().byName.get(name) ?? []; return nodes.find(isBoundedContext); } bc(name: string): BoundedContext | undefined { return this.boundedContext(name); } team(name: string): Team | undefined { const nodes = this.getIndexes().byName.get(name) ?? []; return nodes.find(isTeam); } fqn(node: AstNode): string { if ('name' in node && typeof node.name === 'string' && node.$container) { const container = node.$container; if (isModel(container) || isNamespaceDeclaration(container)) { return this.fqnProvider.getQualifiedName(container, node.name); } } return ''; } /** * Creates a RelationshipView from a Relationship AST node. * Resolves 'this' references to the containing BoundedContext. */ private createRelationshipView( rel: Relationship, containingBc: BoundedContext | undefined, source: 'BoundedContext' | 'ContextMap' ): RelationshipView | undefined { const left = this.resolveContextRef(rel.left, containingBc); const right = this.resolveContextRef(rel.right, containingBc); if (!left || !right) { return undefined; } if (isSymmetricRelationship(rel)) { // Grammar invariant: either `arrow === '><'` (SeparateWays shorthand, pattern undefined) // OR `pattern` is set (explicit [SK]/[P]/[SW] brackets). Never both; never neither // (validation rejects a bare "A B" without pattern or arrow). // The fallback to 'SeparateWays' correctly handles the `><` case. return { type: 'symmetric' as const, kind: (rel.pattern?.$type ?? 'SeparateWays') as 'SharedKernel' | 'Partnership' | 'SeparateWays', left: { context: left, patterns: [] }, right: { context: right, patterns: [] }, source, astNode: rel, }; } const arrow = rel.arrow as '->' | '<-' | '<->'; const leftSide = { context: left, patterns: rel.leftPatterns }; const rightSide = { context: right, patterns: rel.rightPatterns }; const hasSupplier = rel.leftPatterns.some(isSupplier) || rel.rightPatterns.some(isSupplier); const hasCustomer = rel.leftPatterns.some(isCustomer) || rel.rightPatterns.some(isCustomer); const kind: DirectionalKind = arrow === '<->' ? 'Bidirectional' : (hasSupplier || hasCustomer) ? 'CustomerSupplier' : 'UpstreamDownstream'; const upstreamSide = arrow === '->' ? leftSide : arrow === '<-' ? rightSide : undefined; const downstreamSide = arrow === '->' ? rightSide : arrow === '<-' ? leftSide : undefined; return { type: 'directional' as const, kind, arrow, left: leftSide, right: rightSide, upstream: upstreamSide, downstream: downstreamSide, source, astNode: rel, }; } /** * Resolves a BoundedContextRef to a BoundedContext. * Handles 'this' references by using the containing BoundedContext. */ private resolveContextRef( ref: BoundedContextRef, containingBc: BoundedContext | undefined ): BoundedContext | undefined { if (isThisRef(ref)) { return containingBc; } return ref.link?.ref; } } /** * Base implementation of QueryBuilder with lazy iteration. * Predicates are chained and only evaluated during iteration. */ class QueryBuilderImpl implements QueryBuilder { protected readonly sourceItems: T[]; protected readonly predicateList: Array<(item: T) => boolean>; constructor( items: Iterable, protected readonly fqnProvider: QualifiedNameProvider, predicates: Array<(item: T) => boolean> = [] ) { // Materialize generators/iterators into arrays to prevent single-use exhaustion. // Arrays are already re-iterable; this is a no-op for array inputs. this.sourceItems = Array.isArray(items) ? items : [...items]; this.predicateList = predicates; } where(predicate: (item: T) => boolean): QueryBuilder { return new QueryBuilderImpl( this.sourceItems, this.fqnProvider, [...this.predicateList, predicate] ); } withName(pattern: string | RegExp): QueryBuilder { const regex = typeof pattern === 'string' ? new RegExp(`^${escapeRegex(pattern)}$`) : pattern; return this.where((item: T) => { const node = item as unknown as AstNode; if ('name' in node && typeof node.name === 'string') { return regex.test(node.name); } return false; }); } withFqn(pattern: string | RegExp): QueryBuilder { const regex = typeof pattern === 'string' ? new RegExp(`^${escapeRegex(pattern)}$`) : pattern; return this.where((item: T) => { const node = item as unknown as AstNode; if ('name' in node && typeof node.name === 'string' && node.$container) { const container = node.$container; if (isModel(container) || isNamespaceDeclaration(container)) { const fqn = this.fqnProvider.getQualifiedName(container, node.name); return regex.test(fqn); } } return false; }); } first(): T | undefined { const iterator = this[Symbol.iterator](); const result = iterator.next(); return result.done ? undefined : result.value; } toArray(): T[] { return Array.from(this); } count(): number { let count = 0; for (const _ of this) { count++; } return count; } *[Symbol.iterator](): Iterator { for (const item of this.sourceItems) { if (this.predicateList.every(p => p(item))) { yield item; } } } } /** * BoundedContext-specific QueryBuilder with domain filters. * Supports indexed lookups for performance when filters allow. */ class BcQueryBuilderImpl extends QueryBuilderImpl implements BcQueryBuilder { constructor( items: Iterable, fqnProvider: QualifiedNameProvider, private readonly indexes: ModelIndexes, predicates: Array<(item: BoundedContext) => boolean> = [] ) { super(items, fqnProvider, predicates); } override where(predicate: (item: BoundedContext) => boolean): BcQueryBuilder { return new BcQueryBuilderImpl( this.sourceItems, this.fqnProvider, this.indexes, [...this.predicateList, predicate] ); } override withName(pattern: string | RegExp): BcQueryBuilder { return super.withName(pattern) as BcQueryBuilder; } override withFqn(pattern: string | RegExp): BcQueryBuilder { return super.withFqn(pattern) as BcQueryBuilder; } inDomain(domain: string | Domain): BcQueryBuilder { const domainName = typeof domain === 'string' ? domain : domain.name; return this.where(bc => bc.domain?.ref?.name === domainName); } withTeam(team: string | Team): BcQueryBuilder { const teamName = typeof team === 'string' ? team : team.name; // Use index for initial filtering if no predicates yet if (this.predicateList.length === 0) { const indexed = this.indexes.byTeam.get(teamName) ?? []; return new BcQueryBuilderImpl(indexed, this.fqnProvider, this.indexes); } // Add predicate to existing chain return this.where(bc => effectiveTeam(bc)?.name === teamName); } withClassification(classification: string | Classification): BcQueryBuilder { const classificationName = typeof classification === 'string' ? classification : classification.name; // Use index for initial filtering if no predicates yet if (this.predicateList.length === 0) { const indexed = this.indexes.byClassification.get(classificationName) ?? []; return new BcQueryBuilderImpl(indexed, this.fqnProvider, this.indexes); } // Add predicate to existing chain return this.where(bc => effectiveClassification(bc)?.name === classificationName); } withMetadata(key: string, value?: string): BcQueryBuilder { // Use index for initial filtering if no predicates yet and no value specified if (this.predicateList.length === 0 && value === undefined) { const indexed = this.indexes.byMetadataKey.get(key) ?? []; return new BcQueryBuilderImpl(indexed, this.fqnProvider, this.indexes); } // Add predicate to existing chain return this.where(bc => { const metadata = metadataAsMap(bc); const metaValue = metadata.get(key); if (metaValue === undefined) { return false; } return value === undefined || metaValue === value; }); } } /** * Escapes special regex characters in a string. */ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); } /** * Augments BoundedContext instances with SDK-resolved properties. * Called during model loading to enrich the AST. * * Properties use natural names (not sdk* prefix) per PRS: * "Properties are discoverable in IDE autocomplete. The SDK enriches the AST * during load, so `bc.classification` just works—no imports needed." * * Note: We use getters to avoid computing values until accessed. * Note: We use Object.defineProperty to avoid modifying the original interface. * * @param bc - BoundedContext to augment */ export function augmentBoundedContext(bc: BoundedContext): void { // Define computed properties with getters for lazy evaluation // Only include properties that add value beyond direct AST access: // - effectiveClassification/effectiveTeam: array precedence resolution // - metadataMap: array to Map conversion // - fqn: computed qualified name // - helper methods: hasClassification, hasTeam, hasMetadata Object.defineProperties(bc, { effectiveClassification: { get: () => effectiveClassification(bc), enumerable: true, configurable: true, }, effectiveTeam: { get: () => effectiveTeam(bc), enumerable: true, configurable: true, }, metadataMap: { get: () => metadataAsMap(bc), enumerable: true, configurable: true, }, fqn: { get: () => { if (bc.$container && (isModel(bc.$container) || isNamespaceDeclaration(bc.$container))) { return fqnProvider.getQualifiedName(bc.$container, bc.name); } return bc.name; }, enumerable: false, configurable: true, }, // Helper methods hasClassification: { value: (name: string | Classification): boolean => { const classification = effectiveClassification(bc); if (!classification) return false; const targetName = typeof name === 'string' ? name : name?.name; if (!targetName) return false; return classification.name === targetName; }, enumerable: false, configurable: true, }, hasTeam: { value: (name: string | Team): boolean => { const team = effectiveTeam(bc); if (!team) return false; const targetName = typeof name === 'string' ? name : name?.name; if (!targetName) return false; return team.name === targetName; }, enumerable: false, configurable: true, }, hasMetadata: { value: (key: string, value?: string): boolean => { const metadata = metadataAsMap(bc); const metaValue = metadata.get(key); if (metaValue === undefined) return false; return value === undefined || metaValue === value; }, enumerable: false, configurable: true, }, }); } /** * Augments Domain instances with SDK-resolved properties. * Called during model loading to enrich the AST. * * Only includes properties that add value beyond direct AST access: * - fqn: computed qualified name * - hasType: helper method * * Direct access (no augmentation needed): * - domain.description * - domain.vision * - domain.type?.ref * * @param domain - Domain to augment */ export function augmentDomain(domain: Domain): void { Object.defineProperties(domain, { fqn: { get: () => { if (domain.$container && (isModel(domain.$container) || isNamespaceDeclaration(domain.$container))) { return fqnProvider.getQualifiedName(domain.$container, domain.name); } return domain.name; }, enumerable: false, configurable: true, }, // Helper methods hasType: { value: (name: string | Classification): boolean => { const type = domain.type?.ref; if (!type) return false; const targetName = typeof name === 'string' ? name : name?.name; if (!targetName) return false; return type.name === targetName; }, enumerable: false, configurable: true, }, }); } /** * Augments Relationship instances with SDK helper methods. * Called during model loading to enrich the AST. * * @param rel - Relationship to augment * @param containingBc - BoundedContext containing this relationship (for 'this' resolution) */ export function augmentRelationship(rel: Relationship, containingBc?: BoundedContext): void { Object.defineProperties(rel, { leftContextName: { get: () => { if (isThisRef(rel.left)) { return containingBc?.name ?? 'this'; } return rel.left.link?.ref?.name ?? ''; }, enumerable: true, configurable: true, }, rightContextName: { get: () => { if (isThisRef(rel.right)) { return containingBc?.name ?? 'this'; } return rel.right.link?.ref?.name ?? ''; }, enumerable: true, configurable: true, }, isBidirectional: { get: () => rel.arrow === '<->', enumerable: true, configurable: true, }, // Helper methods for pattern matching (type-safe, no magic strings) hasPattern: { value: (pattern: string): boolean => { if (isDirectionalRelationship(rel)) { return rel.leftPatterns.some(p => matchesPattern(p.$type, pattern)) || rel.rightPatterns.some(p => matchesPattern(p.$type, pattern)); } if (isSymmetricRelationship(rel) && rel.pattern) { return matchesPattern(rel.pattern.$type, pattern); } return false; }, enumerable: false, configurable: true, }, hasLeftPattern: { value: (pattern: string): boolean => { if (isDirectionalRelationship(rel)) { return rel.leftPatterns.some(p => matchesPattern(p.$type, pattern)); } return false; }, enumerable: false, configurable: true, }, hasRightPattern: { value: (pattern: string): boolean => { if (isDirectionalRelationship(rel)) { return rel.rightPatterns.some(p => matchesPattern(p.$type, pattern)); } return false; }, enumerable: false, configurable: true, }, isUpstream: { value: (side: 'left' | 'right'): boolean => { if (!isDirectionalRelationship(rel)) return false; const patterns = side === 'left' ? rel.leftPatterns : rel.rightPatterns; return patterns.some(p => isUpstreamPattern(p.$type)); }, enumerable: false, configurable: true, }, isDownstream: { value: (side: 'left' | 'right'): boolean => { if (!isDirectionalRelationship(rel)) return false; const patterns = side === 'left' ? rel.leftPatterns : rel.rightPatterns; return patterns.some(p => isDownstreamPattern(p.$type)); }, enumerable: false, configurable: true, }, }); } /** * Internal implementation of model augmentation. * Called by ensureAugmented() which tracks augmentation state. * * @param model - Root Model node to augment */ function augmentModelInternal(model: Model): void { for (const node of AstUtils.streamAllContents(model)) { if (isBoundedContext(node)) { augmentBoundedContext(node); // Augment relationships inside this bounded context for (const rel of node.relationships) { augmentRelationship(rel, node); } } else if (isDomain(node)) { augmentDomain(node); } else if (isContextMap(node)) { // Augment relationships in context maps (no containing BC) for (const rel of node.relationships) { augmentRelationship(rel); } } } } /** * Augments all AST nodes in a model with SDK-resolved properties. * * This function walks the entire AST and adds lazy getters for resolved * properties like `effectiveClassification`, `effectiveTeam`, etc. * * Idempotent - safe to call multiple times on the same model. * Automatically called by `fromModel()`, `fromDocument()`, and `fromServices()`. * * @param model - Root Model node to augment */ export function augmentModel(model: Model): void { ensureAugmented(model); }