diff --git a/src/builders/ModelBuilder.ts b/src/builders/ModelBuilder.ts index 920bb1d..92aa92c 100644 --- a/src/builders/ModelBuilder.ts +++ b/src/builders/ModelBuilder.ts @@ -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. @@ -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]) ] @@ -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); } /** @@ -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) { @@ -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 ))) ] ); @@ -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; diff --git a/src/builders/NativeModelRenderer.ts b/src/builders/NativeModelRenderer.ts index f20f029..90c9a98 100644 --- a/src/builders/NativeModelRenderer.ts +++ b/src/builders/NativeModelRenderer.ts @@ -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, @@ -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; @@ -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) { @@ -166,7 +172,10 @@ export const buildAttributeDeclaration = ( dialect: Dialect, tablesByModel: ReadonlyMap ): 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 @@ -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)); diff --git a/src/builders/generatedFile.ts b/src/builders/generatedFile.ts index 585e336..07dfa94 100644 --- a/src/builders/generatedFile.ts +++ b/src/builders/generatedFile.ts @@ -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. @@ -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) }); diff --git a/src/builders/jsonSupport.ts b/src/builders/jsonSupport.ts new file mode 100644 index 0000000..5f24aeb --- /dev/null +++ b/src/builders/jsonSupport.ts @@ -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.` + ); + } +}; diff --git a/src/builders/utils.ts b/src/builders/utils.ts index 2f3991c..5a4e678 100644 --- a/src/builders/utils.ts +++ b/src/builders/utils.ts @@ -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 = (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 @@ -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); @@ -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': @@ -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); + }) ); }; diff --git a/src/dialects/Dialect.ts b/src/dialects/Dialect.ts index de670e2..4359ca1 100644 --- a/src/dialects/Dialect.ts +++ b/src/dialects/Dialect.ts @@ -60,6 +60,7 @@ export interface IColumnMetadata { typeExt: string; dataType?: string; // Rendered decorators expression, e.g. DataType.DECIMAL(7,3) sequelizeType?: ISequelizeDataType; // Format-neutral Sequelize type (key + arguments) + isJson?: boolean; // Column reads back as a parsed JSON value; drives the shared Json TypeScript type primaryKey: boolean; foreignKey?: IColumnForeignKeyMetadata; allowNull: boolean; diff --git a/src/dialects/DialectMariaDB.ts b/src/dialects/DialectMariaDB.ts index 701fa74..ba32bb5 100644 --- a/src/dialects/DialectMariaDB.ts +++ b/src/dialects/DialectMariaDB.ts @@ -17,6 +17,13 @@ import { IForeignKeyColumnRow, IForeignKeyQueryRow, } from './foreignKeys.js'; +import { + extractJsonColumnsFromCheckConstraints, + extractJsonColumnsFromCreateTable, + getCreateTableStatement, + ICheckConstraintRow, + IShowCreateTableRow, +} from './mariadbJson.js'; interface ITableRow { table_name: string; @@ -326,9 +333,73 @@ export class DialectMariaDB extends Dialect { columnsMetadata.push(columnMetadata); } + // MariaDB reports a JSON column as longtext with a json_valid CHECK constraint. + // Reclassify those columns as JSON so they emit DataTypes.JSON and the Json type. + const jsonColumnNames = await this.fetchJsonColumnNames(connection, config, table); + + for (const columnMetadata of columnsMetadata) { + if (columnMetadata.type !== 'longtext' || !jsonColumnNames.has(columnMetadata.originName)) { + continue; + } + + const jsonSequelizeType = buildSequelizeDataType(DataTypes.JSON, []); + + if (jsonSequelizeType) { + columnMetadata.sequelizeType = jsonSequelizeType; + columnMetadata.dataType = renderDataTypeExpression( + jsonSequelizeType, + DATA_TYPE_NAMESPACES.decorators + ); + columnMetadata.isJson = true; + } + } + return columnsMetadata; } + /** + * Fetch the database column names of a table backed by longtext with a + * `json_valid(...)` CHECK constraint. Detection reads information_schema. + * CHECK_CONSTRAINTS and falls back to parsing `SHOW CREATE TABLE` when that + * view is unavailable (MariaDB before 10.5). + * @param {Sequelize} connection + * @param {IConfig} config + * @param {string} table + * @returns {Promise>} + */ + private async fetchJsonColumnNames( + connection: Sequelize, + config: IConfig, + table: string + ): Promise> { + try { + const rows = await connection.query( + ` + SELECT CHECK_CLAUSE + FROM information_schema.CHECK_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = '${config.connection.database}' AND TABLE_NAME = '${table}'; + `, + { + type: QueryTypes.SELECT, + raw: true, + } + ); + + return extractJsonColumnsFromCheckConstraints(rows); + } + catch { + const rows = await connection.query( + `SHOW CREATE TABLE \`${table}\`;`, + { + type: QueryTypes.SELECT, + raw: true, + } + ); + + return extractJsonColumnsFromCreateTable(getCreateTableStatement(rows[0])); + } + } + /** * Fetch index metadata for the provided table and column * @param {Sequelize} connection diff --git a/src/dialects/DialectMySQL.ts b/src/dialects/DialectMySQL.ts index 9125130..ce0d65f 100644 --- a/src/dialects/DialectMySQL.ts +++ b/src/dialects/DialectMySQL.ts @@ -337,6 +337,7 @@ export class DialectMySQL extends Dialect { sequelizeType, dataType: renderDataTypeExpression(sequelizeType, DATA_TYPE_NAMESPACES.decorators), }, + ...column.DATA_TYPE === 'json' && { isJson: true }, allowNull: column.IS_NULLABLE === 'YES', primaryKey: column.COLUMN_KEY === 'PRI', autoIncrement: column.EXTRA === 'auto_increment', diff --git a/src/dialects/DialectPostgres.ts b/src/dialects/DialectPostgres.ts index 04c9408..d2dd81b 100644 --- a/src/dialects/DialectPostgres.ts +++ b/src/dialects/DialectPostgres.ts @@ -121,6 +121,24 @@ const sequelizeDataTypesMap: { [key: string]: AbstractDataTypeConstructor } = { jsonpath: DataTypes.JSON, } +// Postgres reports array columns with an underscore-prefixed udt_name (e.g. _int4, +// _text). The element type is the same name without the leading underscore. +const POSTGRES_ARRAY_PREFIX = '_'; + +/** + * Report whether a Postgres udt_name denotes an array type. + * @param {string} udtName + * @returns {boolean} + */ +const isPostgresArrayType = (udtName: string): boolean => udtName.startsWith(POSTGRES_ARRAY_PREFIX); + +/** + * Strip the leading underscore of an array udt_name to get its element type name. + * @param {string} udtName + * @returns {string} + */ +const stripPostgresArrayPrefix = (udtName: string): string => udtName.slice(POSTGRES_ARRAY_PREFIX.length); + const jsDataTypesMap: { [key: string]: string } = { int2: 'number', int4: 'number', @@ -181,11 +199,19 @@ export class DialectPostgres extends Dialect { } /** - * Map database data type to javascript data type + * Map database data type to javascript data type. An array type maps to the + * element JS type suffixed with `[]`; an array whose element has no scalar + * mapping degrades to `unknown[]`. * @param {string} dbType * @returns {string} */ public mapDbTypeToJs(dbType: string): string { + if (isPostgresArrayType(dbType)) { + const elementJsType = jsDataTypesMap[stripPostgresArrayPrefix(dbType)]; + + return `${elementJsType ?? 'unknown'}[]`; + } + return jsDataTypesMap[dbType]; } @@ -302,17 +328,24 @@ export class DialectPostgres extends Dialect { ) as IColumnMetadataPostgres[]; for (const column of columns) { - // Unknown data type - if (!this.mapDbTypeToSequelize(column.udt_name)) { + // For an array column the element type drives the mapping; the ARRAY + // wrapper is added afterwards. + const isArray = isPostgresArrayType(column.udt_name); + const elementTypeName = isArray ? stripPostgresArrayPrefix(column.udt_name) : column.udt_name; + + const elementConstructor = this.mapDbTypeToSequelize(elementTypeName); + + // Unknown data type. An array whose element has no scalar mapping is + // reported once and degrades gracefully: no `type` is emitted and the + // TypeScript type falls back to `unknown[]`. + if (!elementConstructor) { warnUnknownMappingForDataType(column.udt_name); } - const sequelizeConstructor = this.mapDbTypeToSequelize(column.udt_name); - // Data type arguments (precision or length) let dataTypeArgs: Array = []; - switch (column.udt_name) { + switch (elementTypeName) { case 'decimal': case 'numeric': case 'float': @@ -331,10 +364,14 @@ export class DialectPostgres extends Dialect { break; } - const sequelizeType = sequelizeConstructor - ? buildSequelizeDataType(sequelizeConstructor, dataTypeArgs) + const elementType = elementConstructor + ? buildSequelizeDataType(elementConstructor, dataTypeArgs) : undefined; + const sequelizeType = elementType && isArray + ? buildSequelizeDataType(DataTypes.ARRAY, [elementType]) + : elementType; + const columnMetadata: IColumnMetadata = { name: column.column_name, originName: column.column_name, @@ -344,6 +381,7 @@ export class DialectPostgres extends Dialect { sequelizeType, dataType: renderDataTypeExpression(sequelizeType, DATA_TYPE_NAMESPACES.decorators), }, + ...!isArray && (elementTypeName === 'json' || elementTypeName === 'jsonb') && { isJson: true }, allowNull: column.is_nullable === 'YES' && !column.is_primary, primaryKey: column.is_primary, autoIncrement: column.is_sequence, diff --git a/src/dialects/dataTypes.ts b/src/dialects/dataTypes.ts index cf777dd..67e3e24 100644 --- a/src/dialects/dataTypes.ts +++ b/src/dialects/dataTypes.ts @@ -3,17 +3,32 @@ import type { AbstractDataTypeConstructor } from 'sequelize'; export type SequelizeDataTypeKey = keyof typeof DataTypes; -export type DataTypeArgument = string | number; +/** + * A single argument of a Sequelize data type. A scalar argument is a number + * (precision, length) or a string (ENUM value); a nested argument is itself a + * data type, so an argument list can carry another data type such as the element + * type of `ARRAY(INTEGER)`. + */ +export type DataTypeArgument = string | number | ISequelizeDataType; /** - * Format-neutral Sequelize data type: a DataTypes key plus its rendered arguments. - * Numeric arguments render bare; string arguments render single-quoted (ENUM values). + * Format-neutral Sequelize data type: a DataTypes key plus its arguments. Scalar + * arguments render bare (numbers) or single-quoted (strings, for ENUM values); a + * nested data type argument renders as its own data type expression. */ export interface ISequelizeDataType { key: SequelizeDataTypeKey; args: DataTypeArgument[]; } +/** + * Type guard for a nested data type argument. + * @param {DataTypeArgument} arg + * @returns {boolean} + */ +export const isNestedDataType = (arg: DataTypeArgument): arg is ISequelizeDataType => + typeof arg === 'object' && arg !== null && 'key' in arg && 'args' in arg; + /** * Namespaces under which a data type expression is rendered. */ @@ -79,15 +94,27 @@ export const buildSequelizeDataType = ( /** * Render a single data type argument. Numbers render bare; strings render - * single-quoted with internal single quotes doubled. + * single-quoted with internal single quotes doubled; a nested data type renders + * as its own expression under the same namespace. * @param {DataTypeArgument} arg + * @param {DataTypeNamespace} namespace * @returns {string} */ -const renderDataTypeArgument = (arg: DataTypeArgument): string => - typeof arg === 'number' ? String(arg) : `'${arg.replace(/'/g, "''")}'`; +const renderDataTypeArgument = (arg: DataTypeArgument, namespace: DataTypeNamespace): string => { + if (typeof arg === 'number') { + return String(arg); + } + + if (typeof arg === 'string') { + return `'${arg.replace(/'/g, "''")}'`; + } + + return renderDataTypeExpression(arg, namespace); +}; /** - * Render a data type expression, e.g. DataType.DECIMAL(7,3) or DataTypes.ENUM('AA','BB'). + * Render a data type expression, e.g. DataType.DECIMAL(7,3), DataTypes.ENUM('AA','BB') + * or DataTypes.ARRAY(DataTypes.INTEGER). * @param {ISequelizeDataType} dataType * @param {DataTypeNamespace} namespace * @returns {string} @@ -97,7 +124,7 @@ export const renderDataTypeExpression = ( namespace: DataTypeNamespace ): string => { const signature = dataType.args.length - ? `(${dataType.args.map(renderDataTypeArgument).join(',')})` + ? `(${dataType.args.map(arg => renderDataTypeArgument(arg, namespace)).join(',')})` : ''; return `${namespace}.${dataType.key}${signature}`; diff --git a/src/dialects/mariadbJson.ts b/src/dialects/mariadbJson.ts new file mode 100644 index 0000000..b5e420d --- /dev/null +++ b/src/dialects/mariadbJson.ts @@ -0,0 +1,78 @@ +/** + * MariaDB stores a `JSON` column as `longtext` with a `CHECK (json_valid())` + * constraint. These helpers recover the JSON columns of a table from the check + * clauses (information_schema.CHECK_CONSTRAINTS) or, as a fallback for MariaDB + * versions without that view, from the `SHOW CREATE TABLE` statement. + */ + +/** + * Match a `json_valid()` call, capturing the column name with its + * optional surrounding backticks stripped. Case-insensitive and global so every + * occurrence in a statement can be scanned. + */ +const JSON_VALID_PATTERN = /json_valid\(\s*`?([^`()\s]+)`?\s*\)/gi; + +/** + * A row of information_schema.CHECK_CONSTRAINTS relevant to JSON detection. + */ +export interface ICheckConstraintRow { + CHECK_CLAUSE: string; +} + +/** + * A row of `SHOW CREATE TABLE`, whose second column holds the create statement. + */ +export interface IShowCreateTableRow { + 'Create Table'?: string; + 'CREATE TABLE'?: string; +} + +/** + * Extract the column names guarded by a `json_valid(...)` check from a set of + * check clauses. + * @param {ICheckConstraintRow[]} rows + * @returns {Set} + */ +export const extractJsonColumnsFromCheckConstraints = (rows: ICheckConstraintRow[]): Set => { + const columns = new Set(); + + for (const row of rows) { + for (const columnName of matchJsonValidColumns(row.CHECK_CLAUSE ?? '')) { + columns.add(columnName); + } + } + + return columns; +}; + +/** + * Extract the column names guarded by a `json_valid(...)` check from a + * `SHOW CREATE TABLE` statement. + * @param {string} createTableStatement + * @returns {Set} + */ +export const extractJsonColumnsFromCreateTable = (createTableStatement: string): Set => + new Set(matchJsonValidColumns(createTableStatement)); + +/** + * Read the create statement out of a `SHOW CREATE TABLE` result row. + * @param {IShowCreateTableRow | undefined} row + * @returns {string} + */ +export const getCreateTableStatement = (row: IShowCreateTableRow | undefined): string => + row?.['Create Table'] ?? row?.['CREATE TABLE'] ?? ''; + +/** + * Collect every column name captured by a `json_valid(...)` call in a string. + * @param {string} value + * @returns {string[]} + */ +const matchJsonValidColumns = (value: string): string[] => { + const columns: string[] = []; + + for (const match of value.matchAll(JSON_VALID_PATTERN)) { + columns.push(match[1]); + } + + return columns; +}; diff --git a/src/tests/integration/ITestMetadata.ts b/src/tests/integration/ITestMetadata.ts index 4800b7b..aacc674 100644 --- a/src/tests/integration/ITestMetadata.ts +++ b/src/tests/integration/ITestMetadata.ts @@ -34,6 +34,14 @@ export interface ITestMetadata { // Expected foreign key constraints per database table name. expectedForeignKeys: Record; paranoidTable?: string; + // A column carrying a database comment. Drives a dedicated test block that + // asserts the comment is emitted as a `/** … */` JSDoc leading comment in the + // generated model file for the given table. + columnComment?: { + table: string; // Table whose generated model file is read + column: string; // Column carrying the comment + comment: string; // Expected comment text + }; triggerTable?: string; secondarySchemaTable?: { schema: string; name: string }; dataTypes: { @@ -42,6 +50,39 @@ export interface ITestMetadata { getColumnNativeDataType: GetColumnNativeDataTypeFn; testValues: [string, any][]; }, + // Array columns (currently Postgres only). Drives a dedicated test block that + // asserts the emitted data type expression and TypeScript type per column and + // round-trips a sample value through the database. + arrayTypes?: { + arrayTypesTable: string; + expected: { + column: string; // Database column name, e.g. 'f_int_array' + nativeType: string; // Native init-options expression, e.g. 'DataTypes.ARRAY(DataTypes.INTEGER)' + decoratorType: string; // Decorators expression, e.g. 'DataType.ARRAY(DataType.INTEGER)' + tsType: string; // Generated TypeScript type, e.g. 'number[]' + value: unknown[]; // Sample value to round-trip + }[]; + }, + // JSON/JSONB columns. Drives a dedicated test block that asserts the emitted + // data type expression and TypeScript type per column, the presence of the + // shared Json support file and its import, and round-trips an object, an + // array and a top-level scalar through the JSON column. + jsonTypes?: { + jsonTypesTable: string; + expected: { + column: string; // Database column name, e.g. 'f_json' + nativeType: string; // Native init-options expression, e.g. 'DataTypes.JSON' + decoratorType: string; // Decorators expression, e.g. 'DataType.JSON' + tsType: string; // Generated TypeScript type: 'Json' or 'string' + }[]; + // Values round-tripped through the JSON column (the first expected entry + // whose tsType is 'Json'). + roundTripValues: { + object: Record; + array: unknown[]; + scalar: string | number | boolean; + }; + }, associations: { leftTableOneToOne: string; // Left table 1:1 relation rightTableOneToOne: string; // Right table 1:1 relation diff --git a/src/tests/integration/TestRunner.ts b/src/tests/integration/TestRunner.ts index 2c42363..bc9d934 100644 --- a/src/tests/integration/TestRunner.ts +++ b/src/tests/integration/TestRunner.ts @@ -341,7 +341,6 @@ export class TestRunner { const tablesOutDir = path.join( process.cwd(), 'src/tests/integration/output-models', `${format}-tables` ); - const tablesIndexDir = path.join(tablesOutDir, 'index.ts'); let connection: Sequelize | undefined; beforeAll(async () => { @@ -362,25 +361,27 @@ export class TestRunner { }; await buildModels(config); - - // The decorators directory-glob registration would also pick up the - // barrel, so it is removed first; native is wired through initModels. - if (format === 'decorators') { - await fs.unlink(tablesIndexDir); - } }); afterAll(async () => { connection && await connection.close(); }); + it('type-checks the generated output under strict mode', async () => { + const diagnostics = await compileGeneratedModels(tablesOutDir, format); + + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCurrentDirectory: () => tablesOutDir, + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => '\n', + }); + + expect(formatted).toBe(''); + expect(diagnostics).toHaveLength(0); + }); + it('should add only the provided tables', async () => { - if (format === 'decorators') { - connection!.addModels([ tablesOutDir ]); - } - else { - await registerGeneratedModels(connection!, tablesOutDir, format); - } + await registerGeneratedModels(connection!, tablesOutDir, format); for (const table of filterTables) { connection!.model(table); @@ -402,7 +403,6 @@ export class TestRunner { const skipTablesOutDir = path.join( process.cwd(), 'src/tests/integration/output-models', `${format}-skip-tables` ); - const skipTablesIndexDir = path.join(skipTablesOutDir, 'index.ts'); let connection: Sequelize | undefined; beforeAll(async () => { @@ -423,25 +423,27 @@ export class TestRunner { }; await buildModels(config); - - // The decorators directory-glob registration would also pick up the - // barrel, so it is removed first; native is wired through initModels. - if (format === 'decorators') { - await fs.unlink(skipTablesIndexDir); - } }); afterAll(async () => { connection && await connection.close(); }); + it('type-checks the generated output under strict mode', async () => { + const diagnostics = await compileGeneratedModels(skipTablesOutDir, format); + + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCurrentDirectory: () => skipTablesOutDir, + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => '\n', + }); + + expect(formatted).toBe(''); + expect(diagnostics).toHaveLength(0); + }); + it('should skip the provided tables', async () => { - if (format === 'decorators') { - connection!.addModels([ skipTablesOutDir ]); - } - else { - await registerGeneratedModels(connection!, skipTablesOutDir, format); - } + await registerGeneratedModels(connection!, skipTablesOutDir, format); for (const table of filterSkipTables) { expect(() => connection!.model(table)).toThrow(); @@ -465,7 +467,6 @@ export class TestRunner { const skipViewsOutDir = path.join( process.cwd(), 'src/tests/integration/output-models', `${format}-skip-views` ); - const skipViewsIndexDir = path.join(skipViewsOutDir, 'index.ts'); let connection: Sequelize | undefined; beforeAll(async () => { @@ -486,25 +487,27 @@ export class TestRunner { }; await buildModels(config); - - // The decorators directory-glob registration would also pick up the - // barrel, so it is removed first; native is wired through initModels. - if (format === 'decorators') { - await fs.unlink(skipViewsIndexDir); - } }); afterAll(async () => { connection && await connection.close(); }); + it('type-checks the generated output under strict mode', async () => { + const diagnostics = await compileGeneratedModels(skipViewsOutDir, format); + + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCurrentDirectory: () => skipViewsOutDir, + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => '\n', + }); + + expect(formatted).toBe(''); + expect(diagnostics).toHaveLength(0); + }); + it('should skip views', async () => { - if (format === 'decorators') { - connection!.addModels([ skipViewsOutDir ]); - } - else { - await registerGeneratedModels(connection!, skipViewsOutDir, format); - } + await registerGeneratedModels(connection!, skipViewsOutDir, format); for (const { name: tableName } of testTables) { connection!.model(tableName); @@ -691,12 +694,6 @@ export class TestRunner { typeName === 'bit' && receivedValueType === 'uint8array') { expect(parseInt(receivedValue[0], 10)).toStrictEqual(typeValue); } - else if (receivedValueType === 'object' && - sequelizeOptions.dialect === 'mariadb' && - typeName === 'json' - ) { - expect(JSON.stringify(receivedValue)).toStrictEqual(typeValue); - } else { expect(receivedValueType).toStrictEqual(expectedValueType); } @@ -704,6 +701,191 @@ export class TestRunner { }); }); + if (testMetadata.arrayTypes) { + const arrayTypes = testMetadata.arrayTypes; + + describe('Array types', () => { + // A dedicated output dir keeps this generation out of the module cache + // the other blocks populate, so native's initModels reflects it. + const arrayOutDir = path.join( + process.cwd(), 'src/tests/integration/output-models', `${format}-array-types` + ); + let connection: Sequelize | undefined; + let generatedModel = ''; + const warnMessages: string[] = []; + + beforeAll(async () => { + connection = new Sequelize({ ...sequelizeOptions }); + await connection.authenticate(); + await initTestDatabase(testMetadata, connection); + + const config: IConfig = { + connection: sequelizeOptions, + metadata: { + ...testMetadata.schema && { schema: testMetadata.schema.name }, + }, + output: { + outDir: arrayOutDir, + clean: true, + } + }; + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + warnMessages.push(args.map(arg => String(arg)).join(' ')); + }); + + try { + await buildModels(config); + } + finally { + warnSpy.mockRestore(); + } + + generatedModel = await fs.readFile( + path.join(arrayOutDir, `${arrayTypes.arrayTypesTable}.ts`), 'utf8' + ); + + await registerGeneratedModels(connection!, arrayOutDir, format); + }); + + afterAll(async () => { + connection && await connection.close(); + }); + + it('emits the array data type expression and the array TypeScript type', () => { + for (const expected of arrayTypes.expected) { + const typeExpression = format === 'decorators' + ? expected.decoratorType + : expected.nativeType; + + expect(generatedModel).toContain(typeExpression); + expect(generatedModel).toContain(expected.tsType); + } + }); + + it('does not warn about unknown data type mappings', () => { + expect(warnMessages.some(message => message.includes('Unknown data type mapping'))) + .toBe(false); + }); + + it('round-trips array values through the database', async () => { + const model = requireConnection(connection).model(arrayTypes.arrayTypesTable); + + const row: Record = {}; + + for (const expected of arrayTypes.expected) { + row[expected.column] = expected.value; + } + + const created = await model.create(row); + expect(created).toBeDefined(); + + const [stored] = await model.findAll({ order: [['id', 'DESC']], limit: 1 }); + const storedJson = stored.toJSON(); + + for (const expected of arrayTypes.expected) { + expect(Array.isArray(storedJson[expected.column])).toBe(true); + expect(storedJson[expected.column]).toHaveLength(expected.value.length); + } + }); + }); + } + + if (testMetadata.jsonTypes) { + const jsonTypes = testMetadata.jsonTypes; + + describe('JSON types', () => { + // A dedicated output dir keeps this generation out of the module cache + // the other blocks populate, so native's initModels reflects it. + const jsonOutDir = path.join( + process.cwd(), 'src/tests/integration/output-models', `${format}-json-types` + ); + let connection: Sequelize | undefined; + let generatedModel = ''; + let supportFile = ''; + + beforeAll(async () => { + connection = new Sequelize({ ...sequelizeOptions }); + await connection.authenticate(); + await initTestDatabase(testMetadata, connection); + + const config: IConfig = { + connection: sequelizeOptions, + metadata: { + ...testMetadata.schema && { schema: testMetadata.schema.name }, + }, + output: { + outDir: jsonOutDir, + clean: true, + } + }; + + await buildModels(config); + + generatedModel = await fs.readFile( + path.join(jsonOutDir, `${jsonTypes.jsonTypesTable}.ts`), 'utf8' + ); + supportFile = await fs.readFile(path.join(jsonOutDir, 'jsonType.ts'), 'utf8'); + + await registerGeneratedModels(connection!, jsonOutDir, format); + }); + + afterAll(async () => { + connection && await connection.close(); + }); + + it('emits the shared Json support file with the recursive union', () => { + expect(supportFile).toContain('export type Json ='); + expect(supportFile).toContain('Json[]'); + expect(supportFile).toContain('[key: string]: Json'); + }); + + it('type-only imports Json into the model file', () => { + expect(generatedModel).toContain('import type { Json } from "./jsonType"'); + }); + + it('emits the data type expression and TypeScript type per column', () => { + for (const expected of jsonTypes.expected) { + const typeExpression = format === 'decorators' + ? expected.decoratorType + : expected.nativeType; + + expect(generatedModel).toContain(typeExpression); + + const typePattern = new RegExp(`${expected.column}[?!:][^\\n]*\\b${expected.tsType}\\b`); + expect(generatedModel).toMatch(typePattern); + + // A plain (non-JSON) column must not be typed as Json. + if (expected.tsType !== 'Json') { + expect(generatedModel).not.toMatch(new RegExp(`${expected.column}[?!:][^\\n]*Json`)); + } + } + }); + + it('round-trips an object, an array and a top-level scalar', async () => { + const jsonColumn = jsonTypes.expected.find(expected => expected.tsType === 'Json'); + expect(jsonColumn).toBeDefined(); + + const model = requireConnection(connection).model(jsonTypes.jsonTypesTable); + const column = jsonColumn!.column; + const values: unknown[] = [ + jsonTypes.roundTripValues.object, + jsonTypes.roundTripValues.array, + jsonTypes.roundTripValues.scalar, + ]; + + for (const value of values) { + const created = await model.create({ [column]: value }); + const id = created.get('id'); + + const reloaded = await model.findByPk(id); + expect(reloaded).not.toBeNull(); + expect(reloaded!.toJSON()[column]).toEqual(value); + } + }); + }); + } + describe('Associations', () => { let connection: Sequelize | undefined; @@ -1318,6 +1500,57 @@ export class TestRunner { }); } + if (testMetadata.columnComment) { + const columnComment = testMetadata.columnComment; + + describe('Column comments', () => { + // A dedicated output dir keeps this generation out of the module cache + // the other blocks populate. + const commentsOutDir = path.join( + process.cwd(), 'src/tests/integration/output-models', `${format}-column-comments` + ); + let connection: Sequelize | undefined; + let generatedModel = ''; + + beforeAll(async () => { + connection = new Sequelize({ ...sequelizeOptions }); + await connection.authenticate(); + await initTestDatabase(testMetadata, connection); + + const config: IConfig = { + connection: sequelizeOptions, + metadata: { + ...testMetadata.schema && { schema: testMetadata.schema.name }, + }, + output: { + outDir: commentsOutDir, + clean: true, + } + }; + + await buildModels(config); + + generatedModel = await fs.readFile( + path.join(commentsOutDir, `${columnComment.table}.ts`), 'utf8' + ); + }); + + afterAll(async () => { + connection && await connection.close(); + }); + + it('emits the column comment as a JSDoc leading comment', () => { + expect(generatedModel).toContain(`/** ${columnComment.comment} */`); + + // The JSDoc precedes the commented column's declaration. + const jsDocPattern = new RegExp( + `/\\*\\* ${columnComment.comment} \\*/[\\s\\S]*?\\b${columnComment.column}\\b` + ); + expect(generatedModel).toMatch(jsDocPattern); + }); + }); + } + if (testMetadata.triggerTable && testMetadata.secondarySchemaTable) { const triggerTableName = testMetadata.triggerTable; const secondarySchemaTable = testMetadata.secondarySchemaTable; diff --git a/src/tests/integration/mariadb/mariadb.test.ts b/src/tests/integration/mariadb/mariadb.test.ts index 201402a..e381180 100644 --- a/src/tests/integration/mariadb/mariadb.test.ts +++ b/src/tests/integration/mariadb/mariadb.test.ts @@ -14,6 +14,7 @@ import { AUTHORS_TABLE_DROP, AUTHORS_TABLE_CREATES, AUTHORS_TABLE_INSERTS, + AUTHORS_FULL_NAME_COMMENT, BOOKS_TABLE_NAME, BOOKS_TABLE_DROP, BOOKS_TABLE_CREATES, @@ -60,6 +61,9 @@ import { SOFT_DELETES_TABLE_NAME, SOFT_DELETES_TABLE_DROP, SOFT_DELETES_TABLE_CREATES, + JSON_TYPES_TABLE_NAME, + JSON_TYPES_TABLE_DROP, + JSON_TYPES_TABLE_CREATES, } from './queries.js'; interface INativeType { @@ -154,6 +158,11 @@ export const testMetadata: ITestMetadata = { createQueries: SOFT_DELETES_TABLE_CREATES, dropQuery: SOFT_DELETES_TABLE_DROP, }, + { + name: JSON_TYPES_TABLE_NAME, + createQueries: JSON_TYPES_TABLE_CREATES, + dropQuery: JSON_TYPES_TABLE_DROP, + }, ], testViews: [ { @@ -220,6 +229,11 @@ export const testMetadata: ITestMetadata = { [ORDER_LINES_TABLE_NAME]: [], }, paranoidTable: SOFT_DELETES_TABLE_NAME, + columnComment: { + table: AUTHORS_TABLE_NAME, + column: 'full_name', + comment: AUTHORS_FULL_NAME_COMMENT, + }, dataTypes: { dataTypesTable: DATA_TYPES_TABLE_NAME, async getColumnNativeDataType( @@ -277,9 +291,32 @@ export const testMetadata: ITestMetadata = { ['multipolygon', geometries.MultiPolygon], ['geometry', geometries.Geometry], // ['geometrycollection', geometries.GeometryCollection], - ['json', JSON.stringify({key1: 'value1', key2: 'value2'})], + // JSON is covered by the dedicated JSON types block: MariaDB stores it as + // longtext with a json_valid CHECK, which the generator maps to DataTypes.JSON. ] }, + jsonTypes: { + jsonTypesTable: JSON_TYPES_TABLE_NAME, + expected: [ + { + column: 'f_json', + nativeType: 'DataTypes.JSON', + decoratorType: 'DataType.JSON', + tsType: 'Json', + }, + { + column: 'f_plain_longtext', + nativeType: 'DataTypes.STRING', + decoratorType: 'DataType.STRING', + tsType: 'string', + }, + ], + roundTripValues: { + object: { key1: 'value1', nested: { flag: true, count: 2 } }, + array: [1, 'two', { three: 3 }], + scalar: 42, + }, + }, associations: { leftTableOneToOne: PERSON_TABLE_NAME, rightTableOneToOne: PASSPORT_TABLE_NAME, diff --git a/src/tests/integration/mariadb/queries.ts b/src/tests/integration/mariadb/queries.ts index 5fec75e..2b02e6a 100644 --- a/src/tests/integration/mariadb/queries.ts +++ b/src/tests/integration/mariadb/queries.ts @@ -44,6 +44,19 @@ export const DATA_TYPES_TABLE_CREATES = [ `, ]; +export const JSON_TYPES_TABLE_NAME = 'json_types'; +export const JSON_TYPES_TABLE_DROP = `DROP TABLE IF EXISTS ${JSON_TYPES_TABLE_NAME} CASCADE`; +export const JSON_TYPES_TABLE_CREATES = [ + ` + CREATE TABLE ${JSON_TYPES_TABLE_NAME} + ( + id INT AUTO_INCREMENT PRIMARY KEY, + f_json JSON NULL, + f_plain_longtext LONGTEXT NULL + ) + `, +]; + export const INDICES_TABLE_NAME = 'indices'; export const INDICES_TABLE_DROP = `DROP TABLE IF EXISTS ${INDICES_TABLE_NAME} CASCADE`; export const INDICES_TABLE_CREATES = [ @@ -65,13 +78,14 @@ export const INDICES_TABLE_CREATES = [ ]; export const AUTHORS_TABLE_NAME = 'authors'; +export const AUTHORS_FULL_NAME_COMMENT = 'Full name of the author'; export const AUTHORS_TABLE_DROP = `DROP TABLE IF EXISTS ${AUTHORS_TABLE_NAME} CASCADE`; export const AUTHORS_TABLE_CREATES = [ ` CREATE TABLE ${AUTHORS_TABLE_NAME} ( author_id INT primary key, - full_name VARCHAR(80) not null + full_name VARCHAR(80) not null COMMENT '${AUTHORS_FULL_NAME_COMMENT}' ); `, ]; diff --git a/src/tests/integration/mssql/mssql.test.ts b/src/tests/integration/mssql/mssql.test.ts index ef5bd37..f17b7e4 100644 --- a/src/tests/integration/mssql/mssql.test.ts +++ b/src/tests/integration/mssql/mssql.test.ts @@ -13,6 +13,7 @@ import { AUTHORS_TABLE_DROP, AUTHORS_TABLE_CREATES, AUTHORS_TABLE_INSERTS, + AUTHORS_FULL_NAME_COMMENT, BOOKS_TABLE_NAME, BOOKS_TABLE_DROP, BOOKS_TABLE_CREATES, @@ -228,6 +229,11 @@ const testMetadata: ITestMetadata = { [ORDER_LINES_TABLE_NAME]: [], }, paranoidTable: SOFT_DELETES_TABLE_NAME, + columnComment: { + table: AUTHORS_TABLE_NAME, + column: 'full_name', + comment: AUTHORS_FULL_NAME_COMMENT, + }, dataTypes: { dataTypesTable: DATA_TYPES_TABLE_NAME, async getColumnNativeDataType( diff --git a/src/tests/integration/mssql/queries.ts b/src/tests/integration/mssql/queries.ts index fa9416f..45f6b21 100644 --- a/src/tests/integration/mssql/queries.ts +++ b/src/tests/integration/mssql/queries.ts @@ -72,6 +72,7 @@ export const INDICES_TABLE_CREATES = [ ]; export const AUTHORS_TABLE_NAME = 'authors'; +export const AUTHORS_FULL_NAME_COMMENT = 'Full name of the author'; export const AUTHORS_TABLE_DROP = `DROP TABLE IF EXISTS ${AUTHORS_TABLE_NAME}`; export const AUTHORS_TABLE_CREATES = [ ` @@ -81,6 +82,13 @@ export const AUTHORS_TABLE_CREATES = [ full_name VARCHAR(80) not null ); `, + ` + EXEC sp_addextendedproperty + @name = N'MS_Description', @value = N'${AUTHORS_FULL_NAME_COMMENT}', + @level0type = N'SCHEMA', @level0name = N'dbo', + @level1type = N'TABLE', @level1name = N'${AUTHORS_TABLE_NAME}', + @level2type = N'COLUMN', @level2name = N'full_name'; + `, ]; export const AUTHORS_TABLE_INSERTS = [ `INSERT INTO ${AUTHORS_TABLE_NAME} VALUES (1, 'Isasc Asimov');`, diff --git a/src/tests/integration/mysql/mysql.test.ts b/src/tests/integration/mysql/mysql.test.ts index dcdbaa3..ccf023d 100644 --- a/src/tests/integration/mysql/mysql.test.ts +++ b/src/tests/integration/mysql/mysql.test.ts @@ -14,6 +14,7 @@ import { AUTHORS_TABLE_DROP, AUTHORS_TABLE_CREATES, AUTHORS_TABLE_INSERTS, + AUTHORS_FULL_NAME_COMMENT, BOOKS_TABLE_NAME, BOOKS_TABLE_DROP, BOOKS_TABLE_CREATES, @@ -60,6 +61,9 @@ import { SOFT_DELETES_TABLE_NAME, SOFT_DELETES_TABLE_DROP, SOFT_DELETES_TABLE_CREATES, + JSON_TYPES_TABLE_NAME, + JSON_TYPES_TABLE_DROP, + JSON_TYPES_TABLE_CREATES, } from './queries.js'; interface INativeType { @@ -154,6 +158,11 @@ const testMetadata: ITestMetadata = { createQueries: SOFT_DELETES_TABLE_CREATES, dropQuery: SOFT_DELETES_TABLE_DROP, }, + { + name: JSON_TYPES_TABLE_NAME, + createQueries: JSON_TYPES_TABLE_CREATES, + dropQuery: JSON_TYPES_TABLE_DROP, + }, ], testViews: [ { @@ -220,6 +229,11 @@ const testMetadata: ITestMetadata = { [ORDER_LINES_TABLE_NAME]: [], }, paranoidTable: SOFT_DELETES_TABLE_NAME, + columnComment: { + table: AUTHORS_TABLE_NAME, + column: 'full_name', + comment: AUTHORS_FULL_NAME_COMMENT, + }, dataTypes: { dataTypesTable: DATA_TYPES_TABLE_NAME, async getColumnNativeDataType( @@ -280,6 +294,22 @@ const testMetadata: ITestMetadata = { ['json', JSON.parse('{"key1": "value1", "key2": "value2"}')], ] }, + jsonTypes: { + jsonTypesTable: JSON_TYPES_TABLE_NAME, + expected: [ + { + column: 'f_json', + nativeType: 'DataTypes.JSON', + decoratorType: 'DataType.JSON', + tsType: 'Json', + }, + ], + roundTripValues: { + object: { key1: 'value1', nested: { flag: true, count: 2 } }, + array: [1, 'two', { three: 3 }], + scalar: 42, + }, + }, associations: { leftTableOneToOne: PERSON_TABLE_NAME, rightTableOneToOne: PASSPORT_TABLE_NAME, diff --git a/src/tests/integration/mysql/queries.ts b/src/tests/integration/mysql/queries.ts index 4cf532b..118844a 100644 --- a/src/tests/integration/mysql/queries.ts +++ b/src/tests/integration/mysql/queries.ts @@ -44,6 +44,18 @@ export const DATA_TYPES_TABLE_CREATES = [ `, ]; +export const JSON_TYPES_TABLE_NAME = 'json_types'; +export const JSON_TYPES_TABLE_DROP = `DROP TABLE IF EXISTS ${JSON_TYPES_TABLE_NAME} CASCADE`; +export const JSON_TYPES_TABLE_CREATES = [ + ` + CREATE TABLE ${JSON_TYPES_TABLE_NAME} + ( + id INT AUTO_INCREMENT PRIMARY KEY, + f_json JSON NULL + ) + `, +]; + export const INDICES_TABLE_NAME = 'indices'; export const INDICES_TABLE_DROP = `DROP TABLE IF EXISTS ${INDICES_TABLE_NAME} CASCADE`; export const INDICES_TABLE_CREATES = [ @@ -65,13 +77,14 @@ export const INDICES_TABLE_CREATES = [ ]; export const AUTHORS_TABLE_NAME = 'authors'; +export const AUTHORS_FULL_NAME_COMMENT = 'Full name of the author'; export const AUTHORS_TABLE_DROP = `DROP TABLE IF EXISTS ${AUTHORS_TABLE_NAME} CASCADE`; export const AUTHORS_TABLE_CREATES = [ ` CREATE TABLE ${AUTHORS_TABLE_NAME} ( author_id INT primary key, - full_name VARCHAR(80) not null + full_name VARCHAR(80) not null COMMENT '${AUTHORS_FULL_NAME_COMMENT}' ); `, ]; diff --git a/src/tests/integration/postgres/postgres.test.ts b/src/tests/integration/postgres/postgres.test.ts index d8d42a0..a94818d 100644 --- a/src/tests/integration/postgres/postgres.test.ts +++ b/src/tests/integration/postgres/postgres.test.ts @@ -16,6 +16,7 @@ import { AUTHORS_TABLE_DROP, AUTHORS_TABLE_CREATES, AUTHORS_TABLE_INSERTS, + AUTHORS_FULL_NAME_COMMENT, BOOKS_TABLE_NAME, BOOKS_TABLE_DROP, BOOKS_TABLE_CREATES, @@ -59,6 +60,12 @@ import { SOFT_DELETES_TABLE_NAME, SOFT_DELETES_TABLE_DROP, SOFT_DELETES_TABLE_CREATES, + ARRAY_TYPES_TABLE_NAME, + ARRAY_TYPES_TABLE_DROP, + ARRAY_TYPES_TABLE_CREATES, + JSON_TYPES_TABLE_NAME, + JSON_TYPES_TABLE_DROP, + JSON_TYPES_TABLE_CREATES, } from "./queries.js"; interface INativeType { @@ -156,6 +163,16 @@ const testMetadata: ITestMetadata = { createQueries: SOFT_DELETES_TABLE_CREATES, dropQuery: SOFT_DELETES_TABLE_DROP, }, + { + name: ARRAY_TYPES_TABLE_NAME, + createQueries: ARRAY_TYPES_TABLE_CREATES, + dropQuery: ARRAY_TYPES_TABLE_DROP, + }, + { + name: JSON_TYPES_TABLE_NAME, + createQueries: JSON_TYPES_TABLE_CREATES, + dropQuery: JSON_TYPES_TABLE_DROP, + }, ], filterTables: [ DATA_TYPES_TABLE_NAME ], filterSkipTables: [ INDICES_TABLE_NAME ], @@ -215,6 +232,11 @@ const testMetadata: ITestMetadata = { [ORDER_LINES_TABLE_NAME]: [], }, paranoidTable: SOFT_DELETES_TABLE_NAME, + columnComment: { + table: AUTHORS_TABLE_NAME, + column: 'full_name', + comment: AUTHORS_FULL_NAME_COMMENT, + }, dataTypes: { dataTypesTable: DATA_TYPES_TABLE_NAME, async getColumnNativeDataType( @@ -275,6 +297,89 @@ const testMetadata: ITestMetadata = { ['jsonb', JSON.parse('{"key1": "value1", "key2": "value2"}')], ], }, + arrayTypes: { + arrayTypesTable: ARRAY_TYPES_TABLE_NAME, + expected: [ + { + column: 'f_int_array', + nativeType: 'DataTypes.ARRAY(DataTypes.INTEGER)', + decoratorType: 'DataType.ARRAY(DataType.INTEGER)', + tsType: 'number[]', + value: [1, 2, 3], + }, + { + column: 'f_bigint_array', + nativeType: 'DataTypes.ARRAY(DataTypes.BIGINT)', + decoratorType: 'DataType.ARRAY(DataType.BIGINT)', + tsType: 'string[]', + value: ['100000000000000000', '2'], + }, + { + column: 'f_text_array', + nativeType: 'DataTypes.ARRAY(DataTypes.STRING)', + decoratorType: 'DataType.ARRAY(DataType.STRING)', + tsType: 'string[]', + value: ['alpha', 'beta'], + }, + { + column: 'f_varchar_array', + nativeType: 'DataTypes.ARRAY(DataTypes.STRING)', + decoratorType: 'DataType.ARRAY(DataType.STRING)', + tsType: 'string[]', + value: ['x', 'y'], + }, + { + column: 'f_boolean_array', + nativeType: 'DataTypes.ARRAY(DataTypes.BOOLEAN)', + decoratorType: 'DataType.ARRAY(DataType.BOOLEAN)', + tsType: 'boolean[]', + value: [true, false], + }, + { + column: 'f_numeric_array', + nativeType: 'DataTypes.ARRAY(DataTypes.DECIMAL)', + decoratorType: 'DataType.ARRAY(DataType.DECIMAL)', + tsType: 'string[]', + value: ['1.5', '2.25'], + }, + { + column: 'f_timestamp_array', + nativeType: 'DataTypes.ARRAY(DataTypes.DATE', + decoratorType: 'DataType.ARRAY(DataType.DATE', + tsType: 'Date[]', + value: [new Date()], + }, + { + column: 'f_uuid_array', + nativeType: 'DataTypes.ARRAY(DataTypes.UUID)', + decoratorType: 'DataType.ARRAY(DataType.UUID)', + tsType: 'string[]', + value: ['a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'], + }, + ], + }, + jsonTypes: { + jsonTypesTable: JSON_TYPES_TABLE_NAME, + expected: [ + { + column: 'f_json', + nativeType: 'DataTypes.JSON', + decoratorType: 'DataType.JSON', + tsType: 'Json', + }, + { + column: 'f_jsonb', + nativeType: 'DataTypes.JSONB', + decoratorType: 'DataType.JSONB', + tsType: 'Json', + }, + ], + roundTripValues: { + object: { key1: 'value1', nested: { flag: true, count: 2 } }, + array: [1, 'two', { three: 3 }], + scalar: 42, + }, + }, associations: { leftTableOneToOne: PERSON_TABLE_NAME, rightTableOneToOne: PASSPORT_TABLE_NAME, diff --git a/src/tests/integration/postgres/queries.ts b/src/tests/integration/postgres/queries.ts index fda5bf9..7ca452a 100644 --- a/src/tests/integration/postgres/queries.ts +++ b/src/tests/integration/postgres/queries.ts @@ -84,6 +84,7 @@ export const INDICES_TABLE_CREATES = [ ]; export const AUTHORS_TABLE_NAME = 'authors'; +export const AUTHORS_FULL_NAME_COMMENT = 'Full name of the author'; export const AUTHORS_TABLE_DROP = `DROP TABLE IF EXISTS ${AUTHORS_TABLE_NAME} CASCADE`; export const AUTHORS_TABLE_CREATES = [ ` @@ -93,6 +94,7 @@ export const AUTHORS_TABLE_CREATES = [ full_name VARCHAR(80) not null ); `, + `COMMENT ON COLUMN ${SCHEMA_NAME}.${AUTHORS_TABLE_NAME}.full_name IS '${AUTHORS_FULL_NAME_COMMENT}';`, ]; export const AUTHORS_TABLE_INSERTS = [ `INSERT INTO ${SCHEMA_NAME}.${AUTHORS_TABLE_NAME} VALUES (1, 'Isasc Asimov');`, @@ -297,3 +299,35 @@ export const SOFT_DELETES_TABLE_CREATES = [ ); `, ]; + +export const JSON_TYPES_TABLE_NAME = 'json_types'; +export const JSON_TYPES_TABLE_DROP = `DROP TABLE IF EXISTS ${SCHEMA_NAME}.${JSON_TYPES_TABLE_NAME} CASCADE`; +export const JSON_TYPES_TABLE_CREATES = [ + ` + CREATE TABLE ${SCHEMA_NAME}.${JSON_TYPES_TABLE_NAME} + ( + id serial not null constraint json_types_pk primary key, + f_json json, + f_jsonb jsonb + ) + `, +]; + +export const ARRAY_TYPES_TABLE_NAME = 'array_types'; +export const ARRAY_TYPES_TABLE_DROP = `DROP TABLE IF EXISTS ${SCHEMA_NAME}.${ARRAY_TYPES_TABLE_NAME} CASCADE`; +export const ARRAY_TYPES_TABLE_CREATES = [ + ` + CREATE TABLE ${SCHEMA_NAME}.${ARRAY_TYPES_TABLE_NAME} + ( + id serial not null constraint array_types_pk primary key, + f_int_array integer[], + f_bigint_array bigint[], + f_text_array text[], + f_varchar_array varchar[], + f_boolean_array boolean[], + f_numeric_array numeric[], + f_timestamp_array timestamp[], + f_uuid_array uuid[] + ); + `, +]; diff --git a/src/tests/unit/builders.test.ts b/src/tests/unit/builders.test.ts index 4be0914..f9aa447 100644 --- a/src/tests/unit/builders.test.ts +++ b/src/tests/unit/builders.test.ts @@ -335,6 +335,16 @@ describe('Builder utils', () => { it('maps an unknown JS type to the unknown keyword', () => { expect(nodeToString(createTypeNodeFromName('whatever'))).toBe('unknown'); }); + + it('maps the any keyword', () => { + expect(nodeToString(createTypeNodeFromName('any'))).toBe('any'); + }); + + it('maps a trailing [] to an array type node', () => { + expect(nodeToString(createTypeNodeFromName('number[]'))).toBe('number[]'); + expect(nodeToString(createTypeNodeFromName('string[]'))).toBe('string[]'); + expect(nodeToString(createTypeNodeFromName('unknown[]'))).toBe('unknown[]'); + }); }); describe('createNullableTypeNode', () => { @@ -388,6 +398,24 @@ describe('Builder utils', () => { expect(printed.replace(/, /g, ',')).toBe(rendered); }); + + it('renders a nested data type argument in the native namespace', () => { + const dataType: ISequelizeDataType = { + key: 'ARRAY', + args: [{ key: 'INTEGER', args: [] }], + }; + expect(nodeToString(buildDataTypeExpression(dataType, DATA_TYPE_NAMESPACES.native))) + .toBe('DataTypes.ARRAY(DataTypes.INTEGER)'); + }); + + it('renders a nested data type argument in the decorators namespace', () => { + const dataType: ISequelizeDataType = { + key: 'ARRAY', + args: [{ key: 'TEXT', args: [] }], + }; + expect(nodeToString(buildDataTypeExpression(dataType, DATA_TYPE_NAMESPACES.decorators))) + .toBe('DataType.ARRAY(DataType.TEXT)'); + }); }); }); diff --git a/src/tests/unit/columnComments.test.ts b/src/tests/unit/columnComments.test.ts new file mode 100644 index 0000000..98e44e5 --- /dev/null +++ b/src/tests/unit/columnComments.test.ts @@ -0,0 +1,130 @@ +import path from 'path'; +import os from 'os'; +import { promises as fs } from 'fs'; +import { sanitiseJsDocComment } from '../../builders/utils.js'; +import { renderNativeModelFile } from '../../builders/NativeModelRenderer.js'; +import { indexTablesByModelName } from '../../builders/nativeAttributes.js'; +import { ModelBuilder } from '../../builders/ModelBuilder.js'; +import { DialectSQLite } from '../../dialects/DialectSQLite.js'; +import type { IConfig } from '../../config/index.js'; +import type { IColumnMetadata, ITableMetadata, ITablesMetadata } from '../../dialects/Dialect.js'; +import type { ISequelizeDataType } from '../../dialects/dataTypes.js'; + +const dialect = new DialectSQLite(); + +const dataType = (key: ISequelizeDataType['key'], ...args: ISequelizeDataType['args']): ISequelizeDataType => ({ key, args }); + +const buildColumn = ( + overrides: Partial & Pick +): IColumnMetadata => ({ + originName: overrides.name, + typeExt: overrides.type, + primaryKey: false, + allowNull: false, + autoIncrement: false, + ...overrides, +}); + +const buildTable = ( + name: string, + columns: IColumnMetadata[], + overrides: Partial = {} +): ITableMetadata => ({ + name, + originName: name, + timestamps: false, + columns: Object.fromEntries(columns.map(column => [column.name, column])), + ...overrides, +}); + +const authors = buildTable('authors', [ + buildColumn({ name: 'author_id', type: 'integer', sequelizeType: dataType('INTEGER'), primaryKey: true, autoIncrement: true }), + buildColumn({ name: 'full_name', type: 'varchar', sequelizeType: dataType('STRING'), comment: 'Full name of the author' }), + buildColumn({ name: 'tricky', type: 'varchar', sequelizeType: dataType('STRING'), allowNull: true, comment: 'ends with */ oops' }), + buildColumn({ name: 'blank', type: 'varchar', sequelizeType: dataType('STRING'), allowNull: true, comment: ' ' }), +]); + +const tablesMetadata: ITablesMetadata = { authors }; +const tablesByModel = indexTablesByModelName(tablesMetadata); + +/** + * SQLite dialect stub that returns fixed table metadata instead of querying a + * live database, so the decorators output can be generated in a unit test. + */ +class StubDialect extends DialectSQLite { + constructor(private readonly stubMetadata: ITablesMetadata) { + super(); + } + + public async buildTablesMetadata(): Promise { + return this.stubMetadata; + } +} + +const createTempDir = (): Promise => fs.mkdtemp(path.join(os.tmpdir(), 'stg-comments-')); + +describe('sanitiseJsDocComment', () => { + it('neutralises an embedded comment terminator so it cannot close the block', () => { + const sanitised = sanitiseJsDocComment('closes here */ and continues'); + + expect(sanitised).not.toContain('*/'); + + const block = `/** ${sanitised} */`; + // A single terminator only: splitting on it yields the body plus an empty tail. + expect(block.split('*/')).toHaveLength(2); + }); + + it('leaves a comment without a terminator unchanged', () => { + expect(sanitiseJsDocComment('Full name of the author')).toBe('Full name of the author'); + }); +}); + +describe('Native format column comments', () => { + const rendered = renderNativeModelFile(authors, dialect, tablesByModel); + + it('emits a JSDoc leading comment on the declare attribute', () => { + expect(rendered).toContain('/** Full name of the author */\n declare full_name'); + }); + + it('sanitises an embedded terminator in the JSDoc but keeps the runtime comment option intact', () => { + expect(rendered).toContain('/** ends with * / oops */'); + expect(rendered).toContain('comment: "ends with */ oops"'); + }); + + it('emits no JSDoc for a whitespace-only comment', () => { + expect(rendered).toContain('declare blank: string | null;'); + expect(rendered).not.toMatch(/\/\*\*[^\n]*\n\s*declare blank/); + }); +}); + +describe('Decorators format column comments', () => { + let generated = ''; + + beforeAll(async () => { + const outDir = await createTempDir(); + const config: IConfig = { + connection: { dialect: 'sqlite' }, + output: { outDir, clean: false }, + format: 'decorators', + strict: true, + }; + + try { + const builder = new ModelBuilder(config, new StubDialect(tablesMetadata)); + await builder.build(); + generated = await fs.readFile(path.join(outDir, 'authors.ts'), 'utf8'); + } + finally { + await fs.rm(outDir, { recursive: true, force: true }); + } + }); + + it('emits the JSDoc on both the class property and the Attributes interface', () => { + const occurrences = generated.match(/\/\*\* Full name of the author \*\//g) ?? []; + expect(occurrences).toHaveLength(2); + }); + + it('emits no JSDoc for a whitespace-only comment', () => { + expect(generated).not.toContain('/** */'); + }); +}); diff --git a/src/tests/unit/dataTypes.test.ts b/src/tests/unit/dataTypes.test.ts index a785267..3ee032f 100644 --- a/src/tests/unit/dataTypes.test.ts +++ b/src/tests/unit/dataTypes.test.ts @@ -66,6 +66,33 @@ describe('data types mapper', () => { const dataType: ISequelizeDataType = { key: 'STRING', args: [255] }; expect(renderDataTypeExpression(dataType, DATA_TYPE_NAMESPACES.decorators)).toBe('DataType.STRING(255)'); }); + + it('renders a nested data type argument in the native namespace', () => { + const dataType: ISequelizeDataType = { + key: 'ARRAY', + args: [{ key: 'INTEGER', args: [] }], + }; + expect(renderDataTypeExpression(dataType, DATA_TYPE_NAMESPACES.native)) + .toBe('DataTypes.ARRAY(DataTypes.INTEGER)'); + }); + + it('renders a nested data type argument in the decorators namespace', () => { + const dataType: ISequelizeDataType = { + key: 'ARRAY', + args: [{ key: 'TEXT', args: [] }], + }; + expect(renderDataTypeExpression(dataType, DATA_TYPE_NAMESPACES.decorators)) + .toBe('DataType.ARRAY(DataType.TEXT)'); + }); + + it('renders arguments of a nested data type argument', () => { + const dataType: ISequelizeDataType = { + key: 'ARRAY', + args: [{ key: 'DECIMAL', args: [7, 3] }], + }; + expect(renderDataTypeExpression(dataType, DATA_TYPE_NAMESPACES.native)) + .toBe('DataTypes.ARRAY(DataTypes.DECIMAL(7,3))'); + }); }); describe('parseEnumValues', () => { diff --git a/src/tests/unit/jsonSupport.test.ts b/src/tests/unit/jsonSupport.test.ts new file mode 100644 index 0000000..5de44d3 --- /dev/null +++ b/src/tests/unit/jsonSupport.test.ts @@ -0,0 +1,113 @@ +import { renderNativeFiles } from '../../builders/generatedFile.js'; +import { + buildJsonTypeImport, + findJsonSupportFileNameCollision, + JSON_SUPPORT_FILE_NAME, + renderJsonSupportFile, + tableHasJsonColumn, + tablesHaveJsonColumn, +} from '../../builders/jsonSupport.js'; +import { nodeToString } from '../../builders/utils.js'; +import { DialectSQLite } from '../../dialects/DialectSQLite.js'; +import type { IColumnMetadata, ITableMetadata, ITablesMetadata } from '../../dialects/Dialect.js'; +import type { ISequelizeDataType } from '../../dialects/dataTypes.js'; + +const dialect = new DialectSQLite(); + +const dataType = (key: ISequelizeDataType['key'], ...args: ISequelizeDataType['args']): ISequelizeDataType => ({ key, args }); + +const buildColumn = ( + overrides: Partial & Pick +): IColumnMetadata => ({ + originName: overrides.name, + typeExt: overrides.type, + primaryKey: false, + allowNull: false, + autoIncrement: false, + ...overrides, +}); + +const buildTable = ( + name: string, + columns: IColumnMetadata[] +): ITableMetadata => ({ + name, + originName: name, + timestamps: false, + columns: Object.fromEntries(columns.map(column => [column.name, column])), +}); + +const jsonTable = buildTable('documents', [ + buildColumn({ name: 'id', type: 'integer', sequelizeType: dataType('INTEGER'), primaryKey: true, autoIncrement: true }), + buildColumn({ name: 'payload', type: 'json', sequelizeType: dataType('JSON'), isJson: true, allowNull: true }), +]); + +const plainTable = buildTable('races', [ + buildColumn({ name: 'race_id', type: 'integer', sequelizeType: dataType('INTEGER'), primaryKey: true, autoIncrement: true }), + buildColumn({ name: 'race_name', type: 'varchar', sequelizeType: dataType('STRING') }), +]); + +describe('renderJsonSupportFile', () => { + it('renders the recursive Json union', () => { + const source = renderJsonSupportFile(); + + expect(source).toContain('export type Json ='); + expect(source).toContain('string'); + expect(source).toContain('number'); + expect(source).toContain('boolean'); + expect(source).toContain('null'); + expect(source).toContain('Json[]'); + expect(source).toContain('[key: string]: Json'); + }); +}); + +describe('buildJsonTypeImport', () => { + it('renders a type-only import from the sibling support file', () => { + expect(nodeToString(buildJsonTypeImport())).toBe('import type { Json } from "./jsonType";'); + }); +}); + +describe('tableHasJsonColumn / tablesHaveJsonColumn', () => { + it('detects a JSON column on a table', () => { + expect(tableHasJsonColumn(jsonTable)).toBe(true); + expect(tableHasJsonColumn(plainTable)).toBe(false); + }); + + it('detects a JSON column across tables', () => { + expect(tablesHaveJsonColumn({ races: plainTable })).toBe(false); + expect(tablesHaveJsonColumn({ races: plainTable, documents: jsonTable })).toBe(true); + }); +}); + +describe('findJsonSupportFileNameCollision', () => { + it('reports a model whose name collides with the support file base name', () => { + const collidingTable = buildTable('jsonType', [ + buildColumn({ name: 'payload', type: 'json', sequelizeType: dataType('JSON'), isJson: true }), + ]); + + expect(findJsonSupportFileNameCollision({ jsonType: collidingTable })).toBe('jsonType'); + expect(findJsonSupportFileNameCollision({ documents: jsonTable })).toBeUndefined(); + }); +}); + +describe('renderNativeFiles JSON support file', () => { + it('emits the support file and a Json import only when a JSON column exists', () => { + const tablesMetadata: ITablesMetadata = { documents: jsonTable }; + const files = renderNativeFiles(tablesMetadata, dialect); + + expect(files.some(file => file.fileName === JSON_SUPPORT_FILE_NAME)).toBe(true); + + const model = files.find(file => file.fileName === 'documents.ts'); + expect(model?.content).toContain('import type { Json } from "./jsonType"'); + expect(model?.content).toContain('payload: Json | null'); + }); + + it('omits the support file when no table has a JSON column', () => { + const files = renderNativeFiles({ races: plainTable }, dialect); + + expect(files.some(file => file.fileName === JSON_SUPPORT_FILE_NAME)).toBe(false); + + const model = files.find(file => file.fileName === 'races.ts'); + expect(model?.content).not.toContain('./jsonType'); + }); +}); diff --git a/src/tests/unit/mariadbJson.test.ts b/src/tests/unit/mariadbJson.test.ts new file mode 100644 index 0000000..0c3c50f --- /dev/null +++ b/src/tests/unit/mariadbJson.test.ts @@ -0,0 +1,57 @@ +import { + extractJsonColumnsFromCheckConstraints, + extractJsonColumnsFromCreateTable, + getCreateTableStatement, +} from '../../dialects/mariadbJson.js'; + +describe('extractJsonColumnsFromCheckConstraints', () => { + it('extracts the column of a json_valid check clause', () => { + const columns = extractJsonColumnsFromCheckConstraints([ + { CHECK_CLAUSE: 'json_valid(`f_json`)' }, + { CHECK_CLAUSE: 'json_valid(`payload`)' }, + ]); + + expect([...columns].sort()).toEqual(['f_json', 'payload']); + }); + + it('tolerates missing backticks and surrounding whitespace', () => { + const columns = extractJsonColumnsFromCheckConstraints([ + { CHECK_CLAUSE: 'json_valid( f_json )' }, + ]); + + expect([...columns]).toEqual(['f_json']); + }); + + it('ignores check clauses that are not json_valid', () => { + const columns = extractJsonColumnsFromCheckConstraints([ + { CHECK_CLAUSE: '`age` > 0' }, + ]); + + expect(columns.size).toBe(0); + }); +}); + +describe('extractJsonColumnsFromCreateTable', () => { + it('extracts json columns from a SHOW CREATE TABLE statement', () => { + const createStatement = [ + 'CREATE TABLE `data_types` (', + ' `id` int(11) NOT NULL AUTO_INCREMENT,', + ' `f_longtext` longtext DEFAULT NULL,', + ' `f_json` longtext CHARACTER SET utf8mb4 DEFAULT NULL CHECK (json_valid(`f_json`)),', + ' PRIMARY KEY (`id`)', + ')', + ].join('\n'); + + const columns = extractJsonColumnsFromCreateTable(createStatement); + + expect([...columns]).toEqual(['f_json']); + }); +}); + +describe('getCreateTableStatement', () => { + it('reads the create statement regardless of column-name case', () => { + expect(getCreateTableStatement({ 'Create Table': 'CREATE TABLE a' })).toBe('CREATE TABLE a'); + expect(getCreateTableStatement({ 'CREATE TABLE': 'CREATE TABLE b' })).toBe('CREATE TABLE b'); + expect(getCreateTableStatement(undefined)).toBe(''); + }); +});