@ardatan/relay-compiler
Version:
A compiler tool for building GraphQL-driven applications.
433 lines (376 loc) • 13.5 kB
Flow
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
// flowlint ambiguous-object-type:error
;
const CodegenDirectory = require('./CodegenDirectory');
const CodegenWatcher = require('./CodegenWatcher');
const GraphQLWatchmanClient = require('../core/GraphQLWatchmanClient');
const Profiler = require('../core/GraphQLCompilerProfiler');
const invariant = require('invariant');
const path = require('path');
const {create: createSchema} = require('../core/Schema');
/* $FlowFixMe[untyped-import] - importing immutable, which is untyped (and flow
* is sad about it) */
const {Map: ImmutableMap} = require('immutable');
import type ASTCache from '../core/ASTCache';
import type {Schema} from '../core/Schema';
import type {Reporter} from '../reporters/Reporter';
import type {CompileResult, File} from './CodegenTypes';
import type {FileFilter, WatchmanExpression} from './CodegenWatcher';
import type {SourceControl} from './SourceControl';
import type {DocumentNode, Source} from 'graphql';
export type ParserConfig = {|
baseDir: string,
getFileFilter?: (baseDir: string) => FileFilter,
getParser: (baseDir: string) => ASTCache,
getSchemaSource: () => Source,
schemaExtensions: $ReadOnlyArray<string>,
generatedDirectoriesWatchmanExpression?: ?WatchmanExpression,
watchmanExpression?: ?WatchmanExpression,
filepaths?: ?Array<string>,
|};
type ParserConfigs = {[parser: string]: ParserConfig, ...};
type Parsers = {[parser: string]: ASTCache, ...};
export type IsGeneratedFileFn = (filePath: string) => boolean;
export type KeepExtraFileFn = (filePath: string) => boolean;
export type WriterConfig = {|
parser: string,
baseParsers?: Array<string>,
isGeneratedFile: IsGeneratedFileFn,
writeFiles: WriteFiles,
|};
type WriterConfigs = {[writer: string]: WriterConfig, ...};
export type WriteFilesOptions = {|
onlyValidate: boolean,
schema: Schema,
documents: ImmutableMap<string, DocumentNode>,
baseDocuments: ImmutableMap<string, DocumentNode>,
sourceControl: ?SourceControl,
reporter: Reporter,
generatedDirectories?: Array<string>,
|};
export type WriteFiles = WriteFilesOptions => Promise<
Map<string, CodegenDirectory>,
>;
type OnCompleteCallback = (
codegenDirs: $ReadOnlyArray<CodegenDirectory>,
) => void;
class CodegenRunner {
parserConfigs: ParserConfigs;
writerConfigs: WriterConfigs;
onlyValidate: boolean;
parsers: Parsers;
onComplete: ?OnCompleteCallback;
// parser => writers that are affected by it
parserWriters: {[parser: string]: Set<string>, ...};
_reporter: Reporter;
_sourceControl: ?SourceControl;
constructor(options: {
parserConfigs: ParserConfigs,
writerConfigs: WriterConfigs,
onlyValidate: boolean,
reporter: Reporter,
sourceControl: ?SourceControl,
onComplete?: OnCompleteCallback,
...
}) {
this.parsers = {};
this.parserConfigs = options.parserConfigs;
this.writerConfigs = options.writerConfigs;
this.onlyValidate = options.onlyValidate;
this.onComplete = options.onComplete;
this._reporter = options.reporter;
this._sourceControl = options.sourceControl;
this.parserWriters = {};
for (const parser in options.parserConfigs) {
this.parserWriters[parser] = new Set();
}
for (const writer in options.writerConfigs) {
const config = options.writerConfigs[writer];
config.baseParsers &&
config.baseParsers.forEach(parser =>
this.parserWriters[parser].add(writer),
);
this.parserWriters[config.parser].add(writer);
}
}
async compileAll(): Promise<CompileResult> {
// reset the parsers
this.parsers = {};
for (const parserName in this.parserConfigs) {
try {
await this.parseEverything(parserName);
} catch (e) {
this._reporter.reportError('CodegenRunner.compileAll', e);
return 'ERROR';
}
}
let hasChanges = false;
for (const writerName in this.writerConfigs) {
const result = await this.write(writerName);
if (result === 'ERROR') {
return 'ERROR';
}
if (result === 'HAS_CHANGES') {
hasChanges = true;
}
}
return hasChanges ? 'HAS_CHANGES' : 'NO_CHANGES';
}
async compile(writerName: string): Promise<CompileResult> {
const writerConfig = this.writerConfigs[writerName];
const parsers = [writerConfig.parser];
if (writerConfig.baseParsers) {
writerConfig.baseParsers.forEach(parser => parsers.push(parser));
}
// Don't bother resetting the parsers
await Profiler.asyncContext('CodegenRunner:parseEverything', () =>
Promise.all(parsers.map(parser => this.parseEverything(parser))),
);
return await this.write(writerName);
}
getDirtyWriters(filePaths: Array<string>): Promise<Set<string>> {
return Profiler.asyncContext('CodegenRunner:getDirtyWriters', async () => {
const dirtyWriters = new Set();
// Check if any files are in the output
for (const configName in this.writerConfigs) {
const config = this.writerConfigs[configName];
for (const filePath of filePaths) {
if (config.isGeneratedFile(filePath)) {
dirtyWriters.add(configName);
}
}
}
// Check for files in the input
await Promise.all(
Object.keys(this.parserConfigs).map(parserConfigName =>
Profiler.waitFor('Watchman:query', async () => {
const client = new GraphQLWatchmanClient();
const config = this.parserConfigs[parserConfigName];
const dirs = await client.watchProject(config.baseDir);
const relativeFilePaths = filePaths.map(filePath =>
path.relative(config.baseDir, filePath),
);
const query = {
expression: [
'allof',
config.watchmanExpression,
['name', relativeFilePaths, 'wholename'],
],
fields: ['exists'],
relative_root: dirs.relativePath,
};
const result = await client.command('query', dirs.root, query);
client.end();
if (result.files.length > 0) {
this.parserWriters[parserConfigName].forEach(writerName =>
dirtyWriters.add(writerName),
);
}
}),
),
);
return dirtyWriters;
});
}
async parseEverything(parserName: string): Promise<void> {
if (this.parsers[parserName]) {
// no need to parse
return;
}
const parserConfig = this.parserConfigs[parserName];
this.parsers[parserName] = parserConfig.getParser(parserConfig.baseDir);
const filter = parserConfig.getFileFilter
? parserConfig.getFileFilter(parserConfig.baseDir)
: anyFileFilter;
if (parserConfig.filepaths && parserConfig.watchmanExpression) {
throw new Error(
'Provide either `watchmanExpression` or `filepaths` but not both.',
);
}
let files;
if (parserConfig.watchmanExpression) {
files = await CodegenWatcher.queryFiles(
parserConfig.baseDir,
parserConfig.watchmanExpression,
filter,
);
} else if (parserConfig.filepaths) {
files = await CodegenWatcher.queryFilepaths(
parserConfig.baseDir,
parserConfig.filepaths,
filter,
);
} else {
throw new Error(
'Either `watchmanExpression` or `filepaths` is required to query files',
);
}
this.parseFileChanges(parserName, files);
}
parseFileChanges(parserName: string, files: Set<File>): void {
return Profiler.run('CodegenRunner.parseFileChanges', () => {
const parser = this.parsers[parserName];
// this maybe should be await parser.parseFiles(files);
parser.parseFiles(files);
});
}
// We cannot do incremental writes right now.
// When we can, this could be writeChanges(writerName, parserName, parsedDefinitions)
write(writerName: string): Promise<CompileResult> {
return Profiler.asyncContext('CodegenRunner.write', async () => {
try {
this._reporter.reportMessage(`\nWriting ${writerName}`);
const {
writeFiles,
parser,
baseParsers,
isGeneratedFile,
} = this.writerConfigs[writerName];
let baseDocuments = ImmutableMap();
if (baseParsers) {
baseParsers.forEach(baseParserName => {
invariant(
this.parsers[baseParserName] != null,
'Trying to access an uncompiled base parser config: %s',
baseParserName,
);
baseDocuments = baseDocuments.merge(
this.parsers[baseParserName].documents(),
);
});
}
const {
baseDir,
generatedDirectoriesWatchmanExpression,
} = this.parserConfigs[parser];
let generatedDirectories = [];
if (generatedDirectoriesWatchmanExpression) {
const relativePaths = await CodegenWatcher.queryDirectories(
baseDir,
generatedDirectoriesWatchmanExpression,
);
generatedDirectories = relativePaths.map(x => path.join(baseDir, x));
}
// always create a new writer: we have to write everything anyways
const documents = this.parsers[parser].documents();
const schema = Profiler.run('getSchema', () =>
createSchema(
this.parserConfigs[parser].getSchemaSource(),
baseDocuments.toArray(),
this.parserConfigs[parser].schemaExtensions,
),
);
const outputDirectories = await writeFiles({
onlyValidate: this.onlyValidate,
schema,
documents,
baseDocuments,
generatedDirectories,
sourceControl: this._sourceControl,
reporter: this._reporter,
});
for (const dir of outputDirectories.values()) {
const all = [
...dir.changes.created,
...dir.changes.updated,
...dir.changes.deleted,
...dir.changes.unchanged,
];
for (const filename of all) {
const filePath = dir.getPath(filename);
invariant(
isGeneratedFile(filePath),
'CodegenRunner: %s returned false for isGeneratedFile, ' +
'but was in generated directory',
filePath,
);
}
}
const onCompleteCallback = this.onComplete;
if (onCompleteCallback != null) {
onCompleteCallback(Array.from(outputDirectories.values()));
}
const combinedChanges = CodegenDirectory.combineChanges(
Array.from(outputDirectories.values()),
);
this._reporter.reportMessage(
CodegenDirectory.formatChanges(combinedChanges, {
onlyValidate: this.onlyValidate,
}),
);
return CodegenDirectory.hasChanges(combinedChanges)
? 'HAS_CHANGES'
: 'NO_CHANGES';
} catch (e) {
this._reporter.reportError('CodegenRunner.write', e);
return 'ERROR';
}
});
}
async watchAll(): Promise<void> {
// get everything set up for watching
await this.compileAll();
for (const parserName in this.parserConfigs) {
await this.watch(parserName);
}
}
async watch(parserName: string): Promise<void> {
const parserConfig = this.parserConfigs[parserName];
if (!parserConfig.watchmanExpression) {
throw new Error('`watchmanExpression` is required to watch files');
}
// watchCompile starts with a full set of files as the changes
// But as we need to set everything up due to potential parser dependencies,
// we should prevent the first watch callback from doing anything.
let firstChange = true;
await CodegenWatcher.watchCompile(
parserConfig.baseDir,
parserConfig.watchmanExpression,
parserConfig.getFileFilter
? parserConfig.getFileFilter(parserConfig.baseDir)
: anyFileFilter,
async files => {
invariant(
this.parsers[parserName] != null,
'Trying to watch an uncompiled parser config: %s',
parserName,
);
if (firstChange) {
firstChange = false;
return;
}
const dependentWriters = [];
this.parserWriters[parserName].forEach(writer =>
dependentWriters.push(writer),
);
try {
if (!this.parsers[parserName]) {
// have to load the parser and make sure all of its dependents are set
await this.parseEverything(parserName);
} else {
this.parseFileChanges(parserName, files);
}
await Promise.all(dependentWriters.map(writer => this.write(writer)));
} catch (error) {
this._reporter.reportError('CodegenRunner.watch', error);
}
this._reporter.reportMessage(
`Watching for changes to ${parserName}...`,
);
},
);
this._reporter.reportMessage(`Watching for changes to ${parserName}...`);
}
}
function anyFileFilter(file: File): boolean {
return true;
}
module.exports = CodegenRunner;