From 49538ec74dd0aecf2d73394a4e68b9d17df73aa3 Mon Sep 17 00:00:00 2001 From: Tom Gobich Date: Mon, 16 Sep 2024 19:32:00 -0400 Subject: [PATCH] wip(stub): working on getting the stubbed relationships added in, need formatting, cleaning up, and to add imports --- commands/generate_models.ts | 11 +- package.json | 2 +- src/db/schema.ts | 5 +- src/extractors/type_extractor.ts | 167 +++++++++++++++++++++++++++++++ src/model/column.ts | 26 ++++- src/model/index.ts | 13 +-- src/model/relationship.ts | 23 ++++- stubs/generate/model.stub | 19 ++++ 8 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 src/extractors/type_extractor.ts create mode 100644 stubs/generate/model.stub diff --git a/commands/generate_models.ts b/commands/generate_models.ts index 2801948..87e6299 100644 --- a/commands/generate_models.ts +++ b/commands/generate_models.ts @@ -2,6 +2,7 @@ import { BaseCommand } from '@adonisjs/core/ace' import { CommandOptions } from '@adonisjs/core/types/ace' import Model from '../src/model/index.js' import { schema } from '../src/db/schema.js' +import { stubsRoot } from '../stubs/main.js' export default class GenerateModels extends BaseCommand { static commandName = 'generate:models' @@ -12,10 +13,18 @@ export default class GenerateModels extends BaseCommand { } async run() { + const codemods = await this.createCodemods() const db = await this.app.container.make('lucid.db') const { tables } = await schema(db) const models = Model.build(tables) - console.log({ models }) + for (let model of models) { + await codemods.makeUsingStub(stubsRoot, 'generate/model.stub', { + model, + relationships: model.relationships.reduce<{ decorator: string; property: string; }[]>((relationships, relationship) => { + return [...relationships, ...relationship.getDefinitions(model.name)] + }, []) + }) + } } } diff --git a/package.json b/package.json index 6d98bbb..d3aa53a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "quick:test": "node --import=./tsnode.esm.js --enable-source-maps bin/test.ts", "pretest": "npm run lint", "test": "c8 npm run quick:test", - "prebuild": "npm run lint && npm run clean", + "prebuild": "npm run clean", "build": "tsc", "postbuild": "npm run copy:templates && npm run index:commands", "index:commands": "adonis-kit index build/commands", diff --git a/src/db/schema.ts b/src/db/schema.ts index ac71bfc..043b168 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -8,11 +8,14 @@ export type TableSchema = { columns: Column[] } +const ignoreTables = ['adonis_schema', 'adonis_schema_versions'] + export async function schema(db: Database) { const knex = db.connection().getWriteClient() const inspector = schemaInspector(knex) const tableNames = await inspector.tables() - const promises = tableNames.map(async (name) => ({ + const targetTableNames = tableNames.filter((name) => !ignoreTables.includes(name)) + const promises = targetTableNames.map(async (name) => ({ name, columns: await inspector.columnInfo(name), })) diff --git a/src/extractors/type_extractor.ts b/src/extractors/type_extractor.ts new file mode 100644 index 0000000..96cca77 --- /dev/null +++ b/src/extractors/type_extractor.ts @@ -0,0 +1,167 @@ +/** + * Database Type to TypeScript Type Extractor + * TODO: Add support for MSSql and other missing Lucid Db Drivers + * + * Based on ehmpathy/sql-code-generator: + * https://github.com/ehmpathy/sql-code-generator/blob/main/src/logic/sqlToTypeDefinitions/resource/common/extractDataTypeFromColumnOrArgumentDefinitionSql.ts + */ + +// #region Strings + +// https://dev.mysql.com/doc/refman/8.0/en/string-types.html +const mysqlStringTypes = [ + 'CHAR', + 'VARCHAR', + 'BLOB', + 'TEXT', + 'TINYTEXT', + 'MEDIUMTEXT', + 'LONGTEXT', + 'ENUM', + 'SET' +] + +//www.postgresql.org/docs/9.5/datatype-character.html +const pgStringTypes = [ + 'CHARACTER', + 'CHAR', + 'CHARACTER VARYING', + 'VARCHAR', + 'TEXT', + 'UUID', +] + +const dbStringTypes = new Set([ + ...mysqlStringTypes, + ...pgStringTypes +]) + +// #endregion +// #region Numbers + +// https://dev.mysql.com/doc/refman/8.0/en/numeric-types.html +const mysqlNumberTypes = [ + 'INTEGER', + 'INT', + 'SMALLINT', + 'TINYINT', + 'MEDIUMINT', + 'BIGINT', + 'DECIMAL', + 'NUMERIC', + 'FLOAT', + 'DOUBLE', +] + +// https://www.postgresql.org/docs/9.5/datatype-numeric.html +const pgNumberTypes = [ + 'SMALLINT', + 'INT2', + 'INTEGER', + 'INT', + 'INT4', + 'BIGINT', + 'INT8', + 'DECIMAL', + 'NUMERIC', + 'REAL', + 'DOUBLE PRECISION', + 'FLOAT8', + 'SMALLSERIAL', + 'SERIAL2', + 'SERIAL', + 'SERIAL4', + 'BIGSERIAL', + 'SERIAL8', +] + +const dbNumberTypes = new Set([ + ...mysqlNumberTypes, + ...pgNumberTypes +]) + +// #endregion +// #region Dates + +// https://dev.mysql.com/doc/refman/8.0/en/date-and-time-types.html +const mysqlDateTypes = ['DATE', 'TIME', 'DATETIME', 'TIMESTAMP', 'YEAR'] + +// https://www.postgresql.org/docs/9.5/datatype-datetime.html +const pgDateTypes = [ + 'TIMESTAMP', + 'TIMESTAMPTZ', + 'TIMESTAMP WITH TIME ZONE', + 'DATE', + 'TIME', + 'TIMETZ', + 'TIME WITH TIMEZONE', +] + +const dbDateTypes = new Set([ + ...mysqlDateTypes, + ...pgDateTypes +]) + +// #endregion +// #region Binary + +// https://dev.mysql.com/doc/refman/8.0/en/binary-varbinary.html +const mysqlBinaryTypes = ['BINARY', 'VARBINARY']; + +// https://www.postgresql.org/docs/9.5/datatype-binary.html +const pgBinaryTypes = ['BYTEA']; + +const dbBinaryTypes = new Set([ + ...mysqlBinaryTypes, + ...pgBinaryTypes +]) + +// #endregion +// #region Booleans + +// https://dev.mysql.com/doc/refman/8.1/en/numeric-type-syntax.html +const mysqlBooleanTypes = ['BOOL', 'BOOLEAN'] + +// https://www.postgresql.org/docs/9.5/datatype-boolean.html +const pgBooleanTypes = ['BOOLEAN'] + +const dbBooleanTypes = new Set([ + ...mysqlBooleanTypes, + ...pgBooleanTypes +]) + +// #endregion +// #region JSON + +// https://dev.mysql.com/doc/refman/8.0/en/json.html +const mysqlJsonTypes = ['JSON'] + +// https://www.postgresql.org/docs/9.5/datatype-json.html +const pgJsonTypes = ['JSON', 'JSONB'] + +const dbJsonTypes = new Set([ + ...mysqlJsonTypes, + ...pgJsonTypes +]) + +// #endregion + +export function extractColumnTypeScriptType(dbDataType: string) { + const isArray = dbDataType.endsWith('[]') + const normalizedDbType = dbDataType.toUpperCase().split('[]')[0].trim() + + if (dbStringTypes.has(normalizedDbType)) + return isArray ? 'string[]' : 'string' + if (dbNumberTypes.has(normalizedDbType)) + return isArray ? 'number[]' : 'number' + if (dbDateTypes.has(normalizedDbType)) + return 'DateTime' + if (dbBinaryTypes.has(normalizedDbType)) + return 'Buffer' + if (dbBooleanTypes.has(normalizedDbType)) + return 'boolean' + if (dbJsonTypes.has(normalizedDbType)) + return 'Record' + + return 'unknown' +} diff --git a/src/model/column.ts b/src/model/column.ts index 03276b0..65894ea 100644 --- a/src/model/column.ts +++ b/src/model/column.ts @@ -3,12 +3,14 @@ import { Column } from 'knex-schema-inspector/dist/types/column.js' import RelationshipTypes from '../enums/relationship_types.js' import Model from './index.js' import ModelRelationship from './relationship.js' +import { extractColumnTypeScriptType } from '../extractors/type_extractor.js' export default class ModelColumn { declare name: string declare columnName: string declare tableName: string - declare type: string // TODO - potential reference: https://github.com/danvk/pg-to-ts/blob/master/src/schemaPostgres.ts + declare type: string + declare typeDb: string declare isPrimary: boolean declare isNullable: boolean declare isDateTime: boolean @@ -18,6 +20,8 @@ export default class ModelColumn { constructor(info: Column) { this.name = string.camelCase(info.name) + this.type = extractColumnTypeScriptType(info.data_type) + this.typeDb = info.data_type this.columnName = info.name this.tableName = info.table this.isPrimary = info.is_primary_key @@ -31,6 +35,26 @@ export default class ModelColumn { return this.columnName.endsWith('_id') } + getDecorator() { + if (this.isPrimary) { + return '@column({ isPrimary: true })' + } + + if (this.type === 'DateTime' && this.name === 'createdAt') { + return '@column.dateTime({ autoCreate: true })' + } + + if (this.type === 'DateTime' && this.name === 'updatedAt') { + return '@column.dateTime({ autoCreate: true, autoUpdate: true })' + } + + if (this.type === 'DateTime') { + return '@column.dateTime()' + } + + return '@column()' + } + getRelationship(tables: Model[]) { if (!this.isIdColumn || !this.foreignKeyColumn) return diff --git a/src/model/index.ts b/src/model/index.ts index dc674d2..faa43c3 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -5,6 +5,7 @@ import ModelRelationship from './relationship.js' export default class Model { declare name: string + declare fileName: string declare tableName: string declare columns: ModelColumn[] declare relationships: ModelRelationship[] @@ -13,24 +14,24 @@ export default class Model { constructor(name: string, columns: ModelColumn[]) { this.name = generators.modelName(name) + this.fileName = generators.modelFileName(name) this.tableName = name this.columns = columns } - setPivotTable(isPivotTable: boolean) { - this.isPivotTable = isPivotTable - } - static build(tables: TableSchema[]) { const models = this.#getModelsFromTables(tables) const relationships = ModelRelationship.parse(models) for (let model of models) { const ships = relationships.get(model.name) - model.relationships = [...(ships?.values() || [])] + const values = [...(ships?.values() || [])] + + model.relationships = values + model.isPivotTable = values.filter((relation) => relation.isManyToMany)?.length >= 2 } - return models + return models.filter((model) => !model.isPivotTable) } static #getModelsFromTables(tables: TableSchema[]) { diff --git a/src/model/relationship.ts b/src/model/relationship.ts index 2e8d9c4..a9ab3df 100644 --- a/src/model/relationship.ts +++ b/src/model/relationship.ts @@ -9,6 +9,7 @@ export type ModelRelationshipInfo = { modelColumn: string tableName: string tableColumn: string + relatedModelName: string } export default class ModelRelationship { @@ -34,6 +35,7 @@ export default class ModelRelationship { tableColumn: parentColumn!, modelName: parentModel!.name, modelColumn: this.#getModelColumn(parentModel!, parentColumn!), + relatedModelName: childModel!.name } this.child = { @@ -42,11 +44,18 @@ export default class ModelRelationship { tableColumn: childColumn!, modelName: childModel!.name, modelColumn: this.#getModelColumn(childModel!, childColumn!), + relatedModelName: parentModel!.name } this.#setTypes(type, column, models) } + get isManyToMany() { + const isParent = this.parent.type === RelationshipTypes.MANY_TO_MANY + const isChild = this.child.type === RelationshipTypes.MANY_TO_MANY + return isParent && isChild + } + getDefinitions(modelName: string) { const definitions = [] @@ -62,9 +71,19 @@ export default class ModelRelationship { } #getDefinition(info: ModelRelationshipInfo) { + let propertyName = string.camelCase(info.relatedModelName) + + switch (info.type) { + case RelationshipTypes.HAS_MANY: + case RelationshipTypes.MANY_TO_MANY: + propertyName = string.plural(propertyName) + break + } + return { - decorator: `@${info.type}(() => ${info.modelName})`, - property: `declare ${info.modelColumn}: ${string.capitalCase(info.type)}`, + type: info.type, + decorator: `@${info.type}(() => ${info.relatedModelName})`, + property: `declare ${propertyName}: ${string.pascalCase(info.type)}`, } } diff --git a/stubs/generate/model.stub b/stubs/generate/model.stub new file mode 100644 index 0000000..9e0b514 --- /dev/null +++ b/stubs/generate/model.stub @@ -0,0 +1,19 @@ +import { DateTime } from 'luxon' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +export default class {{ model.name }} extends BaseModel { + {{ #each model.columns as column }} + {{ column.getDecorator() }} + declare {{ column.name }}: {{ column.type }} + {{ /each }} + {{ #each relationships as relationship }} + {{ relationship.decorator }} + {{{ relationship.property }}} + {{ /each }} +} + +{{{ + exports({ + to: app.modelsPath(model.fileName) + }) +}}}