cassandra-codegen
Version:
Generate type definitions from a Cassandra database
175 lines (153 loc) • 6.81 kB
text/typescript
import {Client, mapping} from 'cassandra-driver';
import {Project, StructureKind, VariableDeclarationKind} from 'ts-morph';
import {lowerFirst, upperFirst} from 'lodash';
import {cassandraTypeToTsType} from "./cassandra-types";
import {basename, join} from "path";
type CassandraColumnInfo = {
column_name: string,
clustering_order: 'ASC' | 'DESC' | 'NONE',
kind: 'partition_key' | 'clustering' | 'regular',
position: number,
type: string,
};
const DEFAULT_GENERATED_FILENAME = 'generated.ts';
const MAPPINGS_OBJECT_VARIABLE_NAME = 'mappingsObject';
const underscoreCqlToCamelCaseMappingsObject = new mapping.UnderscoreCqlToCamelCaseMappings();
function snakeCaseToCamelCase(s: string) {
// The cassandra-driver implementation is slightly different from that of Lodash.
// Also, it assumes that the input is in snake case, which is why we convert it to lowercase.
return underscoreCqlToCamelCaseMappingsObject.getPropertyName(s.toLowerCase());
}
async function getTableNames(client: Client, keyspaceName: string): Promise<string[]> {
const query = 'SELECT table_name FROM system_schema.tables WHERE keyspace_name = ?';
const result = await client.execute(query, [keyspaceName]);
return result.rows.map(row => row.table_name);
}
async function getTableColumns(client: Client, keyspaceName: string, tableName: string): Promise<CassandraColumnInfo[]> {
const query = 'SELECT * FROM system_schema.columns WHERE keyspace_name = ? AND table_name = ?';
const columns = (await client.execute(query, [keyspaceName, tableName])).rows as unknown as CassandraColumnInfo[];
return columns
.sort((a, b) => a.position - b.position)
.sort((a, b) => a.kind === b.kind ? 0 : a.kind === 'partition_key' || (a.kind === 'clustering' && b.kind === 'regular') ? -1 : 1);
}
export async function generateTypeScriptDefinitions(
client: Client,
keyspaceName: string,
outputDir: string,
typeNameSuffix: string,
generateTsFile: boolean,
useJsMap: boolean,
useJsSet: boolean
) {
const startTime = performance.now();
const tableNames = await getTableNames(client, keyspaceName);
if (!tableNames.length) {
console.warn(`Keyspace ${keyspaceName} has no tables. Nothing to generate.`);
return;
}
const tsMorphProject = new Project({compilerOptions: {outDir: outputDir, declaration: true, 'sourceMap': true}});
const sourceFile = tsMorphProject.createSourceFile(join(outputDir, DEFAULT_GENERATED_FILENAME), {}, {overwrite: true});
sourceFile.addImportDeclarations([
{
namedImports: ['PartitionKey', 'Clustering'],
moduleSpecifier: './utils',
},
{
namedImports: ['CodegenModelMapper'],
moduleSpecifier: './mapper',
},
{
namedImports: ['Client', 'mapping', 'types'],
moduleSpecifier: 'cassandra-driver',
}
]);
for (const tableName of tableNames) {
const columns = await getTableColumns(client, keyspaceName, tableName);
const interfaceDeclaration = sourceFile.addInterface({
name: upperFirst(snakeCaseToCamelCase(tableName) + typeNameSuffix),
isExported: true,
});
columns.forEach(column => {
let tsType = cassandraTypeToTsType(column.type, useJsMap, useJsSet);
const isPartOfPrimaryKey = ['partition_key', 'clustering'].includes(column.kind);
if (isPartOfPrimaryKey) {
if (column.kind === 'partition_key') {
tsType = `PartitionKey<${tsType}>`;
} else if (column.kind === 'clustering') {
tsType = `Clustering<${tsType}, '${column.clustering_order}'>`;
}
} else {
tsType += ' | null';
}
interfaceDeclaration.addProperty({
name: snakeCaseToCamelCase(column.column_name),
type: tsType,
hasQuestionToken: !isPartOfPrimaryKey,
});
});
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Let,
declarations: [{
name: snakeCaseToCamelCase(tableName) + 'Mapper',
type: `CodegenModelMapper<${interfaceDeclaration.getName()}>`,
}],
isExported: true,
});
console.log(`Generated type & mapper for table "${tableName}"`);
}
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [{
name: MAPPINGS_OBJECT_VARIABLE_NAME,
initializer: 'new mapping.UnderscoreCqlToCamelCaseMappings()'
}],
});
sourceFile.addFunction({
name: 'initMappers',
isExported: true,
isAsync: true,
parameters: [
{
name: 'client',
type: 'Client',
}
],
statements: [
{
kind: StructureKind.VariableStatement,
declarationKind: VariableDeclarationKind.Const,
declarations: [{
name: 'mapper',
initializer: writer => {
writer.writeLine('new mapping.Mapper(client, {');
writer.writeLine('models: {');
writer.setIndentationLevel(1);
tableNames.forEach(tableName => {
const modelName = upperFirst(snakeCaseToCamelCase(tableName));
writer.writeLine(`'${modelName}': { tables: ['${tableName}'], mappings: ${MAPPINGS_OBJECT_VARIABLE_NAME} },`);
});
writer.setIndentationLevel(0);
writer.writeLine('}})');
},
}],
},
writer => {
tableNames.forEach(tableName => {
const modelName = upperFirst(snakeCaseToCamelCase(tableName));
const mapperName = lowerFirst(modelName) + 'Mapper';
writer.writeLine(`${mapperName} = mapper.forModel('${modelName}');`);
});
}
],
});
if (generateTsFile) {
await tsMorphProject.save();
} else {
await tsMorphProject.emit();
}
const pluralSuffix = tableNames.length === 1 ? '' : 's';
const timeTookInMs = Math.round(performance.now() - startTime);
const outputPath = join(outputDir, basename(DEFAULT_GENERATED_FILENAME, '.ts'))
+ (generateTsFile ? '.ts' : '.{js, d.ts}');
console.log(`✓ Introspected ${tableNames.length} table${pluralSuffix} and generated ${outputPath} in ${timeTookInMs}ms.`);
}