Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions src/builders/ModelBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,41 @@ import { resolveAssociationPropertyName } from './associationNaming.js';
import { IGeneratedFile, renderNativeFiles, writeGeneratedFiles } from './generatedFile.js';
import { warnWhenDecoratorsDependencyIsMissing } from './decoratorsDependency.js';
import {
attachColumnCommentJsDoc,
nodeToString,
createGenericTypeReference,
createTypeNodeFromName,
generateArrowDecorator,
generateNamedImports,
generateObjectLiteralDecorator,
generateIndexExport,
} from './utils.js';
import {
buildJsonTypeImport,
JSON_SUPPORT_FILE_NAME,
JSON_TYPE_NAME,
renderJsonSupportFile,
tableHasJsonColumn,
tablesHaveJsonColumn,
warnJsonSupportFileNameCollision,
} from './jsonSupport.js';

export { resolveAssociationPropertyName } from './associationNaming.js';

const foreignKeyDecorator = 'ForeignKey';

/**
* Build the TypeScript type node of a column: the shared `Json` type for a JSON
* column, otherwise the dialect JS mapping.
* @param {IColumnMetadata} col
* @param {Dialect} dialect
* @returns {ts.TypeNode}
*/
const buildColumnTypeNode = (col: IColumnMetadata, dialect: Dialect): ts.TypeNode =>
col.isJson
? createGenericTypeReference(JSON_TYPE_NAME, [])
: createTypeNodeFromName(dialect.mapDbTypeToJs(col.type) ?? 'any');

/**
* Build the `@Table` decorator options for a table. The `hasTrigger` flag is
* emitted only when the source table carries at least one enabled trigger.
Expand Down Expand Up @@ -142,7 +166,7 @@ export class ModelBuilder extends Builder {
};


return ts.factory.createPropertyDeclaration(
const propertyDeclaration = ts.factory.createPropertyDeclaration(
[
...(col.foreignKey ?
[ generateArrowDecorator(foreignKeyDecorator, [col.foreignKey.targetModel]) ]
Expand All @@ -158,9 +182,11 @@ export class ModelBuilder extends Builder {
col.name,
(col.autoIncrement || col.allowNull || col.defaultValue !== undefined) ?
ts.factory.createToken(ts.SyntaxKind.QuestionToken) : ts.factory.createToken(ts.SyntaxKind.ExclamationToken),
ts.factory.createTypeReferenceNode(dialect.mapDbTypeToJs(col.type) ?? 'any', undefined),
buildColumnTypeNode(col, dialect),
undefined,
);

return attachColumnCommentJsDoc(propertyDeclaration, col.comment);
}

/**
Expand Down Expand Up @@ -221,6 +247,12 @@ export class ModelBuilder extends Builder {
generatedCode += '\n';
});

// Type-only import of the shared Json type for JSON/JSONB columns.
if (tableHasJsonColumn(tableMetadata)) {
generatedCode += nodeToString(buildJsonTypeImport());
generatedCode += '\n';
}

const attributesInterfaceName = `${name}Attributes`;

if (strict) {
Expand All @@ -234,12 +266,15 @@ export class ModelBuilder extends Builder {
undefined,
undefined,
[
...(Object.values(columns).map(c => ts.factory.createPropertySignature(
undefined,
ts.factory.createIdentifier(c.name),
c.autoIncrement || c.allowNull || c.defaultValue !== undefined ?
ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined,
ts.factory.createTypeReferenceNode(dialect.mapDbTypeToJs(c.type) ?? 'any', undefined)
...(Object.values(columns).map(c => attachColumnCommentJsDoc(
ts.factory.createPropertySignature(
undefined,
ts.factory.createIdentifier(c.name),
c.autoIncrement || c.allowNull || c.defaultValue !== undefined ?
ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined,
buildColumnTypeNode(c, dialect)
),
c.comment
)))
]
);
Expand Down Expand Up @@ -338,6 +373,11 @@ export class ModelBuilder extends Builder {
content: ModelBuilder.buildTableClassDeclaration(tableMetadata, dialect, strict),
}));

if (tablesHaveJsonColumn(tablesMetadata)) {
warnJsonSupportFileNameCollision(tablesMetadata);
files.push({ fileName: JSON_SUPPORT_FILE_NAME, content: renderJsonSupportFile() });
}

files.push({ fileName: 'index.ts', content: ModelBuilder.buildIndexExports(tablesMetadata) });

return files;
Expand Down
16 changes: 15 additions & 1 deletion src/builders/NativeModelRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { IColumnMetadata, ITableMetadata } from '../dialects/Dialect.js';
import type { IAssociationMetadata } from '../dialects/AssociationsParser.js';
import { DATA_TYPE_NAMESPACES } from '../dialects/dataTypes.js';
import {
attachColumnCommentJsDoc,
buildDataTypeExpression,
buildObjectLiteralExpression,
createGenericTypeReference,
Expand Down Expand Up @@ -35,6 +36,7 @@ import {
resolveAssociationForeignKey,
} from './nativeAssociations.js';
import { resolveAssociationPropertyName } from './associationNaming.js';
import { buildJsonTypeImport, JSON_TYPE_NAME, tableHasJsonColumn } from './jsonSupport.js';

const NATIVE_NAMESPACE = DATA_TYPE_NAMESPACES.native;

Expand Down Expand Up @@ -92,6 +94,10 @@ const buildBaseTypeNode = (column: IColumnMetadata, table: ITableMetadata, diale
return createTypeNodeFromName('Date');
}

if (column.isJson) {
return createGenericTypeReference(JSON_TYPE_NAME, []);
}

const { sequelizeType } = column;

if (sequelizeType && sequelizeType.key === 'ENUM' && sequelizeType.args.length > 0) {
Expand Down Expand Up @@ -166,7 +172,10 @@ export const buildAttributeDeclaration = (
dialect: Dialect,
tablesByModel: ReadonlyMap<string, ITableMetadata>
): ts.PropertyDeclaration =>
buildDeclareField(column.name, buildAttributeTypeNode(column, table, dialect, tablesByModel));
attachColumnCommentJsDoc(
buildDeclareField(column.name, buildAttributeTypeNode(column, table, dialect, tablesByModel)),
column.comment
);

/**
* Build the `declare createdAt`/`declare updatedAt` members of a model, emitted
Expand Down Expand Up @@ -547,6 +556,11 @@ export const renderNativeModelFile = (
code += '\n';
}

if (tableHasJsonColumn(table)) {
code += nodeToString(buildJsonTypeImport());
code += '\n';
}

code += '\n';
code += nodeToString(buildModelClassDeclaration(table, dialect, tablesByModel));

Expand Down
11 changes: 11 additions & 0 deletions src/builders/generatedFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import type { Dialect, ITablesMetadata } from '../dialects/Dialect.js';
import { indexTablesByModelName } from './nativeAttributes.js';
import { renderNativeModelFile } from './NativeModelRenderer.js';
import { renderInitModelsFile, renderNativeIndexFile } from './nativeWiring.js';
import {
JSON_SUPPORT_FILE_NAME,
renderJsonSupportFile,
tablesHaveJsonColumn,
warnJsonSupportFileNameCollision,
} from './jsonSupport.js';

/**
* A rendered source file to write to the output directory.
Expand All @@ -28,6 +34,11 @@ export const renderNativeFiles = (tablesMetadata: ITablesMetadata, dialect: Dial
content: renderNativeModelFile(table, dialect, tablesByModel),
}));

if (tablesHaveJsonColumn(tablesMetadata)) {
warnJsonSupportFileNameCollision(tablesMetadata);
files.push({ fileName: JSON_SUPPORT_FILE_NAME, content: renderJsonSupportFile() });
}

files.push({ fileName: 'initModels.ts', content: renderInitModelsFile(tablesMetadata, tablesByModel) });
files.push({ fileName: 'index.ts', content: renderNativeIndexFile(tablesMetadata) });

Expand Down
121 changes: 121 additions & 0 deletions src/builders/jsonSupport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import * as ts from 'typescript';
import type { ITableMetadata, ITablesMetadata } from '../dialects/Dialect.js';
import { generateTypeOnlyImport, nodeToString } from './utils.js';

/**
* Base name of the shared support file holding the recursive `Json` type. Chosen
* to be distinct from generated model names; a collision with a model name is
* reported by `findJsonSupportFileNameCollision`.
*/
export const JSON_SUPPORT_FILE_BASENAME = 'jsonType';

/**
* File name of the shared JSON support file.
*/
export const JSON_SUPPORT_FILE_NAME = `${JSON_SUPPORT_FILE_BASENAME}.ts`;

/**
* Exported type name of the recursive JSON union.
*/
export const JSON_TYPE_NAME = 'Json';

/**
* Sibling module specifier used by model files to import the `Json` type.
*/
export const JSON_SUPPORT_MODULE_SPECIFIER = `./${JSON_SUPPORT_FILE_BASENAME}`;

/**
* Build the recursive `Json` type alias declaration:
* `export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };`
* @returns {ts.TypeAliasDeclaration}
*/
export const buildJsonTypeAliasDeclaration = (): ts.TypeAliasDeclaration => {
const jsonReference = ts.factory.createTypeReferenceNode(JSON_TYPE_NAME, undefined);

const indexSignature = ts.factory.createTypeLiteralNode([
ts.factory.createIndexSignature(
undefined,
[ts.factory.createParameterDeclaration(
undefined,
undefined,
'key',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
undefined
)],
jsonReference
),
]);

const union = ts.factory.createUnionTypeNode([
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword),
ts.factory.createLiteralTypeNode(ts.factory.createNull()),
ts.factory.createArrayTypeNode(jsonReference),
indexSignature,
]);

return ts.factory.createTypeAliasDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
JSON_TYPE_NAME,
undefined,
union
);
};

/**
* Render the shared JSON support file source.
* @returns {string}
*/
export const renderJsonSupportFile = (): string => nodeToString(buildJsonTypeAliasDeclaration());

/**
* Build the type-only import of the `Json` type from the shared support file.
* @returns {ts.ImportDeclaration}
*/
export const buildJsonTypeImport = (): ts.ImportDeclaration =>
generateTypeOnlyImport([JSON_TYPE_NAME], JSON_SUPPORT_MODULE_SPECIFIER);

/**
* Report whether a table has at least one JSON/JSONB column.
* @param {ITableMetadata} table
* @returns {boolean}
*/
export const tableHasJsonColumn = (table: ITableMetadata): boolean =>
Object.values(table.columns).some(column => column.isJson === true);

/**
* Report whether any table has at least one JSON/JSONB column.
* @param {ITablesMetadata} tablesMetadata
* @returns {boolean}
*/
export const tablesHaveJsonColumn = (tablesMetadata: ITablesMetadata): boolean =>
Object.values(tablesMetadata).some(tableHasJsonColumn);

/**
* Find a generated model whose name collides with the shared support file name,
* returning the model name, or undefined when there is no collision.
* @param {ITablesMetadata} tablesMetadata
* @returns {string | undefined}
*/
export const findJsonSupportFileNameCollision = (tablesMetadata: ITablesMetadata): string | undefined =>
Object.values(tablesMetadata).find(table => table.name === JSON_SUPPORT_FILE_BASENAME)?.name;

/**
* Warn when a generated model name collides with the shared JSON support file
* name, which would make both write to the same file.
* @param {ITablesMetadata} tablesMetadata
* @returns {void}
*/
export const warnJsonSupportFileNameCollision = (tablesMetadata: ITablesMetadata): void => {
const collision = findJsonSupportFileNameCollision(tablesMetadata);

if (collision) {
console.warn(
'[WARNING]',
`Model '${collision}' collides with the shared JSON support file '${JSON_SUPPORT_FILE_NAME}'; ` +
`the generated model and the '${JSON_TYPE_NAME}' type file would overwrite each other.`
);
}
};
57 changes: 50 additions & 7 deletions src/builders/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,36 @@ export const nodeToString = (node: ts.Node): string => {
return unescape(sourceCode.replace(/\\u/g, "%u"));
};

/**
* Neutralise the block-comment terminator inside text so it cannot prematurely
* close a JSDoc block, by inserting a space between `*` and `/`.
* @param {string} comment
* @returns {string}
*/
export const sanitiseJsDocComment = (comment: string): string => comment.replace(/\*\//g, '* /');

/**
* Attach a database column comment as a `/** … *\/` JSDoc leading comment on a
* declaration. Empty or whitespace-only comments are ignored; an embedded
* comment terminator is neutralised first. Returns the node unchanged when there
* is nothing to attach.
* @param {T} node
* @param {string | undefined} comment
* @returns {T}
*/
export const attachColumnCommentJsDoc = <T extends ts.Node>(node: T, comment: string | undefined): T => {
if (!comment || comment.trim().length === 0) {
return node;
}

return ts.addSyntheticLeadingComment(
node,
ts.SyntaxKind.MultiLineCommentTrivia,
`* ${sanitiseJsDocComment(comment.trim())} `,
true
);
};

/**
* Generate named imports code (e.g. `import { Something, Else } from "module"`)
* @param {string[]} importsSpecifier
Expand Down Expand Up @@ -183,12 +213,17 @@ export const buildObjectLiteralExpression = (
);

/**
* Build a type node from a JS type name: number/string/boolean/object keywords,
* Date and Uint8Array references, and the unknown keyword for anything else.
* Build a type node from a JS type name: number/string/boolean/object/any keywords,
* Date and Uint8Array references, and the unknown keyword for anything else. A name
* with a trailing `[]` (e.g. `number[]`) yields an array type node of the element.
* @param {string} jsType
* @returns {ts.TypeNode}
*/
export const createTypeNodeFromName = (jsType: string): ts.TypeNode => {
if (jsType.endsWith('[]')) {
return ts.factory.createArrayTypeNode(createTypeNodeFromName(jsType.slice(0, -2)));
}

switch (jsType) {
case 'number':
return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
Expand All @@ -198,6 +233,8 @@ export const createTypeNodeFromName = (jsType: string): ts.TypeNode => {
return ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
case 'object':
return ts.factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword);
case 'any':
return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword);
case 'Date':
return ts.factory.createTypeReferenceNode('Date', undefined);
case 'Uint8Array':
Expand Down Expand Up @@ -263,11 +300,17 @@ export const buildDataTypeExpression = (
return ts.factory.createCallExpression(
memberAccess,
undefined,
dataType.args.map(arg =>
typeof arg === 'number'
? createNumericLiteralExpression(arg)
: ts.factory.createStringLiteral(arg)
)
dataType.args.map(arg => {
if (typeof arg === 'number') {
return createNumericLiteralExpression(arg);
}

if (typeof arg === 'string') {
return ts.factory.createStringLiteral(arg);
}

return buildDataTypeExpression(arg, namespace);
})
);
};

Expand Down
Loading
Loading