/* eslint-disable no-console */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { join, normalize, resolve } from 'path'; import readdir from 'readdirp'; import { CommandModule } from 'yargs'; import { getTimestamp, green, red } from './utils'; /** * CLI command options object. */ interface GenerateSnippetsOptions { includeMultitenancySnippets: boolean; snippetsFileNameWithoutExtension: string; } /** * Attempts to find a path to the `.vscode` folder. * It will look for either `.vscode` folder or `yarn.lock` file in current * folder, then in folder one hierarchy level up, traversing directories towards * root. * Returns an absolute path to .vscode folder or throws an error. */ const findDotVscodePath = (): string => { let startPath = process.cwd(); let lastPath = ''; while (lastPath !== startPath) { const potentialVscodePath = join(startPath, '.vscode'); const potentialYarnLockPath = join(startPath, 'yarn.lock'); if (existsSync(potentialVscodePath) || existsSync(potentialYarnLockPath)) { return potentialVscodePath; } else { // Preserve currently checked path for while condition comparison lastPath = startPath; // Remove last segment from path, move to directory closer to root startPath = normalize(join(startPath, '..')); } } throw Error( `Unable to determine the ".vscode" folder path. Please create a .vscode folder in the root of your repository and try again.`, ); }; /** * Writes generated snippets object to .vscode folder. Creates a folder if it does not exist. * Returns a success message. */ const writeSourceFile = ( snippetsFileName: string, contents: Record, ): string => { const outDir = findDotVscodePath(); if (!existsSync(outDir)) { mkdirSync(outDir, { recursive: true }); } const filePath = join(outDir, `${snippetsFileName}.code-snippets`); writeFileSync(filePath, JSON.stringify(contents, null, 2), 'utf-8'); return `Success! Snippets file path: ${filePath}`; }; /** * Counter for potential unnamed snippets to generate temporary unique names. */ let unparsedSnippetIndex = 1; /** * Extracts snippet name and prefix from a line that defines a function. * In case it's impossible to extract - placeholder values are used and logged (which is expected to be fixed). * @example `CREATE OR REPLACE FUNCTION ax_utils.raise_error(` -> `{"prefix": "ax-raise-error", "snippetName": "Raise Error (Ax Utils)"} */ const parseFunctionName = ( line: string, snippetJson: Record, ): { prefix: string; snippetName: string } => { const match = new RegExp(/CREATE OR REPLACE FUNCTION (.*)\(/i).exec( line, )?.[1]; let prefix = `unnamed-snippet-${unparsedSnippetIndex}`; let snippetName = `Unnamed Snippet ${unparsedSnippetIndex}`; if (!match) { console.log( red( `Unable to parse the name for the following snippet. Naming the snippet ${prefix} instead.`, ), ); console.log(snippetJson); unparsedSnippetIndex++; } else { prefix = match; const [schemaName, functionName] = match.split('.'); const format = (text: string): string => text .replace(/_/g, ' ') .replace(/(^\w{1})|(\s+\w{1})/g, (letter) => letter.toUpperCase()); snippetName = `${format(functionName)} (${format(schemaName)})`; prefix = `ax-${functionName.replace(/_/g, '-')}`; } return { prefix, snippetName }; }; /** * Reads a file to memory as an array, where each element is a new line. * Goes through the array and generates sql snippet objects based on file contents. * Depends on comment conventions to work properly. */ const populateSnippets = async ( filePath: string, snippetsObject: Record, ): Promise> => { const contents = readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); const startTag = '/*-snippet'; const endTag = 'snippet-*/'; const funcLine = 'create or replace function'; let readingSnippet = false; let snippetStringContents = ''; let snippetJson: Record | undefined = undefined; for (const line of contents) { if (line.startsWith(startTag)) { readingSnippet = true; continue; } if (line.startsWith(endTag)) { snippetJson = JSON.parse(snippetStringContents); snippetStringContents = ''; readingSnippet = false; continue; } if (snippetJson && line.toLowerCase().startsWith(funcLine)) { const { prefix, snippetName } = parseFunctionName(line, snippetJson); snippetJson = Object.assign({ prefix }, snippetJson); // Adds a property to the first position snippetJson['scope'] = 'sql'; snippetsObject[snippetName] = snippetJson; snippetJson = undefined; continue; } if (readingSnippet) { snippetStringContents += line; } } return snippetsObject; }; /** * Reads all `.sql` files in `migrations` folder (and sub-folders), extracts snippet json objects from them, and returns a combined object with all snippets. */ const getParsedSnippets = async ( dirPath: string, includeMultitenancySnippets: boolean, ): Promise> => { const parsedSnippets: Record = {}; for await (const { fullPath, path } of readdir(dirPath, { fileFilter: (entry) => entry.basename.endsWith('.sql'), })) { if (!includeMultitenancySnippets && path.includes('multitenancy')) { continue; } console.log(getTimestamp(), `Parsing ${path}`); await populateSnippets(fullPath, parsedSnippets); } return parsedSnippets; }; /** * Reads all `.code-snippets` files in `migrations` folder (and sub-folders) and returns a combined object with all snippets. * These files contain custom snippets that are not tied to any one utils or define sql function. */ const getCustomSnippets = async ( dirPath: string, includeMultitenancySnippets: boolean, ): Promise> => { let customSnippets: Record = {}; for await (const { fullPath, path } of readdir(dirPath, { fileFilter: (entry) => entry.basename.endsWith('.code-snippets'), })) { if (!includeMultitenancySnippets && path.includes('multitenancy')) { continue; } console.log(getTimestamp(), `Reading ${path}`); const fileContents = JSON.parse(readFileSync(fullPath, 'utf-8')); customSnippets = { ...customSnippets, ...fileContents }; } return customSnippets; }; /** * Iterates over all .sql files inside of the `migrations` folder. * Parses each file based on comments and function definition conventions. * Reads all .code-snippets files inside of the `migrations` folder and adds these snippets to parsed snippets. * Generates or updates a VScode snippets file. */ const generate = async ({ includeMultitenancySnippets, snippetsFileNameWithoutExtension, }: GenerateSnippetsOptions): Promise => { console.log(getTimestamp(), green(`Starting snippets generation!`)); const dirPath = resolve(__dirname, '../../../migrations'); const parsedSnippets = await getParsedSnippets( dirPath, includeMultitenancySnippets, ); const customSnippets = await getCustomSnippets( dirPath, includeMultitenancySnippets, ); const writeResult = writeSourceFile(snippetsFileNameWithoutExtension, { ...parsedSnippets, ...customSnippets, }); console.log(getTimestamp(), green(writeResult)); }; /** * Yargs command module definition for `generate-vscode-sql-snippets` CLI script. */ export const generateVscodeSqlSnippets: CommandModule< unknown, GenerateSnippetsOptions > = { command: 'generate-vscode-sql-snippets', describe: 'Creates or updates a mosaic sql snippets file, providing an easier way to generate database migrations.', builder: (yargs) => yargs .option('includeMultitenancySnippets', { alias: 'm', describe: 'If set to true, would also include snippets for multitenancy define functions.', default: false, boolean: true, hidden: true, }) .option('snippetsFileNameWithoutExtension', { alias: 'f', describe: 'Overrides default snippets file name with a custom value. Extension `code-snippets` will be attached to this value.', default: 'mosaic-sql-migrations', string: true, }), handler: generate, };