From 7074065738386a2c0ba1de938efef1acf8e033b2 Mon Sep 17 00:00:00 2001 From: Tom Gobich Date: Wed, 18 Sep 2024 18:40:02 -0400 Subject: [PATCH] refactor(relationships): refactoring relationship building into an extractor so that we can gather both sides of the many-to-many --- commands/generate_models.ts | 2 +- package.json | 2 +- src/db/schema.ts | 12 +- src/extractors/import_extractor.ts | 29 ++- src/extractors/relationship_extractor.ts | 238 +++++++++++++++++++++++ src/extractors/type_extractor.ts | 5 + src/model/column.ts | 19 +- src/model/index.ts | 30 +-- src/model/relationship.ts | 159 --------------- stubs/generate/model.stub | 3 +- 10 files changed, 301 insertions(+), 198 deletions(-) create mode 100644 src/extractors/relationship_extractor.ts delete mode 100644 src/model/relationship.ts diff --git a/commands/generate_models.ts b/commands/generate_models.ts index d2ec602..e7c9906 100644 --- a/commands/generate_models.ts +++ b/commands/generate_models.ts @@ -15,7 +15,7 @@ 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 tables = await schema(db) const models = Model.build(tables) for (let model of models) { diff --git a/package.json b/package.json index d3aa53a..ee339df 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@adocasts.com/generate-models", "description": "Generate Lucid Models from an existing database schema", - "version": "0.0.0", + "version": "0.1.0-1.dev", "engines": { "node": ">=20.6.0" }, diff --git a/src/db/schema.ts b/src/db/schema.ts index 043b168..ad5621a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -10,6 +10,11 @@ export type TableSchema = { const ignoreTables = ['adonis_schema', 'adonis_schema_versions'] +/** + * parse schema information from the provided database connection + * @param db + * @returns + */ export async function schema(db: Database) { const knex = db.connection().getWriteClient() const inspector = schemaInspector(knex) @@ -20,10 +25,5 @@ export async function schema(db: Database) { columns: await inspector.columnInfo(name), })) - const tables = await Promise.all(promises) - - return { - inspector, - tables, - } + return Promise.all(promises) } diff --git a/src/extractors/import_extractor.ts b/src/extractors/import_extractor.ts index 4a7dbcb..7a4627c 100644 --- a/src/extractors/import_extractor.ts +++ b/src/extractors/import_extractor.ts @@ -15,6 +15,11 @@ class ModelImport { this.isType = isType ?? this.isType } + /** + * converts model imports into import strings, grouped by import path + * @param imports + * @returns + */ static getStatements(imports: ModelImport[]) { const groups = this.#getNamespaceGroups(imports) @@ -33,6 +38,11 @@ class ModelImport { }) } + /** + * group imports by their path + * @param imports + * @returns + */ static #getNamespaceGroups(imports: ModelImport[]) { return imports.reduce>((groups, imp) => { const group = groups[imp.namespace] || [] @@ -49,6 +59,10 @@ class ModelImport { export default class ModelImportManager { #imports = new Map() + /** + * add or update a model import to the #imports map + * @param value + */ add(value: ModelImport) { const name = this.#getName(value) const existing = this.#imports.get(name) @@ -60,10 +74,20 @@ export default class ModelImportManager { this.#imports.set(name, existing ?? value) } + /** + * get name for the import name unique to its namespace path + * @param value + * @returns + */ #getName(value: ModelImport) { return `${value.name}@${value.namespace}` } + /** + * extract import statements from provided model + * @param model + * @returns + */ extract(model: Model) { this.add(new ModelImport('BaseModel', '@adonisjs/lucid/orm')) this.add(new ModelImport('column', '@adonisjs/lucid/orm')) @@ -75,7 +99,10 @@ export default class ModelImportManager { }) model.relationships.map((definition) => { - this.add(new ModelImport(definition.relatedModelName, `./${string.snakeCase(definition.relatedModelName)}.js`, true)) + if (definition.relatedModelName !== model.name) { + this.add(new ModelImport(definition.relatedModelName, `./${string.snakeCase(definition.relatedModelName)}.js`, true)) + } + this.add(new ModelImport(definition.type, '@adonisjs/lucid/orm')) this.add(new ModelImport(string.pascalCase(definition.type), '@adonisjs/lucid/types/relations', false, true)) }) diff --git a/src/extractors/relationship_extractor.ts b/src/extractors/relationship_extractor.ts new file mode 100644 index 0000000..3bae479 --- /dev/null +++ b/src/extractors/relationship_extractor.ts @@ -0,0 +1,238 @@ +import RelationshipTypes from "../enums/relationship_types.js"; +import ModelColumn from "../model/column.js"; +import Model from "../model/index.js"; +import string from '@adonisjs/core/helpers/string'; + +type RelationMap = { + type: RelationshipTypes + model: Model + column: ModelColumn + foreignKeyModel: Model + foreignKeyColumn: ModelColumn + pivotColumnName?: string + pivotTableName?: string +} + +type DecoratorOptions = { + localKey?: string + foreignKey?: string + pivotForeignKey?: string + relatedKey?: string + pivotRelatedForeignKey?: string + pivotTable?: string +} + +export class ModelRelationship { + declare type: RelationshipTypes + declare propertyName: string + declare modelName: string + declare relatedModelName: string + declare decoratorOptions: DecoratorOptions | undefined + declare map: RelationMap + + declare decorator: string + declare property: string + + constructor(relationMap: RelationMap) { + this.type = relationMap.type + this.propertyName = this.#getPropertyName(relationMap) + this.decoratorOptions = this.#getDecoratorOptions(relationMap) + this.modelName = relationMap.model.name + this.relatedModelName = relationMap.foreignKeyModel.name + this.map = relationMap + + const definition = this.#getDefinition() + + this.decorator = definition.decorator + this.property = definition.property + } + + /** + * gets property name for the relationship definition + * @param relationMap + * @returns + */ + #getPropertyName(relationMap: RelationMap) { + let propertyName = string.camelCase(relationMap.foreignKeyModel.name) + + switch (relationMap.type) { + case RelationshipTypes.BELONGS_TO: + return string.camelCase(relationMap.column.name.replace('Id', '')) + case RelationshipTypes.HAS_MANY: + return string.plural(propertyName) + case RelationshipTypes.MANY_TO_MANY: + return string.plural(string.camelCase(relationMap.foreignKeyModel.name)) + } + + return propertyName + } + + /** + * gets the relationship's decorator options (if needed) + * @param relationMap + * @returns + */ + #getDecoratorOptions(relationMap: RelationMap): DecoratorOptions | undefined { + switch (relationMap.type) { + case RelationshipTypes.MANY_TO_MANY: + const defaultPivotName = string.snakeCase([relationMap.model.name, relationMap.foreignKeyModel.name].sort().join('_')) + + if (relationMap.pivotTableName === defaultPivotName) return + + return { + pivotTable: relationMap.pivotTableName, + } + case RelationshipTypes.BELONGS_TO: + const defaultBelongsName = string.camelCase(relationMap.foreignKeyModel.name + 'Id') + + if (relationMap.column.name === defaultBelongsName) return + + return { + foreignKey: relationMap.column.name + } + case RelationshipTypes.HAS_MANY: + case RelationshipTypes.HAS_ONE: + const defaultHasName = string.camelCase(relationMap.model.name + 'Id') + + if (relationMap.foreignKeyColumn.name === defaultHasName) return + + return { + foreignKey: relationMap.foreignKeyColumn.name + } + } + } + + /** + * converts the populated decorator options into a string for the stub + * @returns + */ + #getDecoratorString() { + const keys = Object.keys(this.decoratorOptions || {}) as Array + + if (!keys.length) return '' + + const inner = keys.reduce((str, key) => { + if (str.length) str += ', ' + str += `${key}: '${this.decoratorOptions![key]}'` + return str + }, '') + + return inner ? `, { ${inner} }` : '' + } + + /** + * gets definition decorator and property definition lines for the stub + * @returns + */ + #getDefinition() { + return { + decorator: `@${this.type}(() => ${this.relatedModelName}${this.#getDecoratorString()})`, + property: `declare ${this.propertyName}: ${string.pascalCase(this.type)}` + } + } +} + +export default class ModelRelationshipManager { + + constructor(protected models: Model[]) {} + + /** + * extract relationships from the loaded models + * @returns + */ + extract() { + const relationshpMaps = this.#getRelationshipMaps() + return relationshpMaps.map((map) => new ModelRelationship(map)) + } + + /** + * get mappings for the model's relationships with information + * needed to populate their definitions + * @returns + */ + #getRelationshipMaps() { + const belongsTos = this.#getBelongsTos() + const relationships: RelationMap[] = [] + + belongsTos.map((belongsTo) => { + const tableNamesSingular = this.models + .filter((model) => model.tableName !== belongsTo.column.tableName) + .map((model) => ({ singular: string.singular(model.tableName), model })) + + // try to build a pivot table by matching table names with the current table name + const tableNameSingular = string.singular(belongsTo.column.tableName) + const startsWithTable = tableNamesSingular.find((name) => tableNameSingular.startsWith(name.singular)) + const endsWithTable = tableNamesSingular.find((name) => tableNameSingular.endsWith(name.singular)) + const pivotName = `${startsWithTable?.singular}_${endsWithTable?.singular}` + + // if they match, consider it a pivot and build a many-to-many relationship from the belongsTo info + if (tableNameSingular === pivotName) { + const isStartsWith = startsWithTable?.model.name === belongsTo.foreignKeyModel.name + const relatedModel = isStartsWith ? endsWithTable!.model : startsWithTable!.model + const relatedColumn = isStartsWith + ? relatedModel.columns.find((column) => column.tableName === endsWithTable?.model.tableName) + : relatedModel.columns.find((column) => column.tableName === startsWithTable?.model.tableName) + + // mark the model as a pivot, so it can be ignored + belongsTo.model.isPivotTable = true + + relationships.push({ + type: RelationshipTypes.MANY_TO_MANY, + model: belongsTo.foreignKeyModel, + column: belongsTo.foreignKeyColumn, + foreignKeyModel: relatedModel!, + foreignKeyColumn: relatedColumn!, + pivotColumnName: belongsTo.column.columnName, + pivotTableName: belongsTo.model.tableName, + }) + + return + } + + // otherwise, it'll be a has many ... it may also be a has one, but + // we have no way to discern that, so we'll default to has many + relationships.push({ + type: RelationshipTypes.HAS_MANY, + model: belongsTo.foreignKeyModel, + column: belongsTo.foreignKeyColumn, + foreignKeyModel: belongsTo.model, + foreignKeyColumn: belongsTo.column, + }) + + // tag along the belongs to when it is not converted to a many-to-many + relationships.push(belongsTo) + }) + + return relationships + } + + /** + * get the belongs to relationships from the foreign key definitions on the models + * we'll work backwards from here + * @returns + */ + #getBelongsTos() { + const belongsTos: RelationMap[] = [] + + this.models.map((model) => { + model.columns.map((column) => { + if (!column.foreignKeyTable) return + + const foreignKeyModel = this.models.find((m) => m.tableName === column.foreignKeyTable) + const foreignKeyColumn = foreignKeyModel?.columns.find((c) => column.foreignKeyColumn === c.columnName) + + if (!foreignKeyColumn || !foreignKeyModel) return + + belongsTos.push({ + type: RelationshipTypes.BELONGS_TO, + model, + column, + foreignKeyModel, + foreignKeyColumn, + }) + }) + }) + + return belongsTos + } +} diff --git a/src/extractors/type_extractor.ts b/src/extractors/type_extractor.ts index 96cca77..e6841e5 100644 --- a/src/extractors/type_extractor.ts +++ b/src/extractors/type_extractor.ts @@ -146,6 +146,11 @@ const dbJsonTypes = new Set([ // #endregion +/** + * extract the TypeScript type from the database type + * @param dbDataType + * @returns + */ export function extractColumnTypeScriptType(dbDataType: string) { const isArray = dbDataType.endsWith('[]') const normalizedDbType = dbDataType.toUpperCase().split('[]')[0].trim() diff --git a/src/model/column.ts b/src/model/column.ts index 65894ea..a80df67 100644 --- a/src/model/column.ts +++ b/src/model/column.ts @@ -1,8 +1,6 @@ import string from '@adonisjs/core/helpers/string' 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 { @@ -31,10 +29,10 @@ export default class ModelColumn { this.foreignKeyColumn = info.foreign_key_column } - get isIdColumn() { - return this.columnName.endsWith('_id') - } - + /** + * get the column decorator for the column's type + * @returns + */ getDecorator() { if (this.isPrimary) { return '@column({ isPrimary: true })' @@ -54,13 +52,4 @@ export default class ModelColumn { return '@column()' } - - getRelationship(tables: Model[]) { - if (!this.isIdColumn || !this.foreignKeyColumn) return - - // mark id columns with a foreign key as a belongs to - const type = RelationshipTypes.BELONGS_TO - - return new ModelRelationship(type, this, tables) - } } diff --git a/src/model/index.ts b/src/model/index.ts index 608c1b1..fde78bc 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -1,15 +1,15 @@ import { generators } from '@adonisjs/core/app' import ModelColumn from './column.js' import { TableSchema } from '../db/schema.js' -import ModelRelationship, { ModelRelationshipDefinition } from './relationship.js' import ModelImportManager from '../extractors/import_extractor.js' +import ModelRelationshipManager, { ModelRelationship } from '../extractors/relationship_extractor.js'; export default class Model { declare name: string declare fileName: string declare tableName: string declare columns: ModelColumn[] - declare relationships: ModelRelationshipDefinition[] + declare relationships: ModelRelationship[] declare imports: string[] isPivotTable: boolean = false @@ -21,29 +21,31 @@ export default class Model { this.columns = columns } + /** + * build the model definitions from the tables + * @param tables + * @returns + */ static build(tables: TableSchema[]) { const models = this.#getModelsFromTables(tables) - const relationships = ModelRelationship.parse(models) - + const relationshipManager = new ModelRelationshipManager(models) + const relationships = relationshipManager.extract() + for (let model of models) { - const ships = relationships.get(model.name) - const values = [...(ships?.values() || [])] const importManager = new ModelImportManager() - model.isPivotTable = values.filter((relation) => relation.isManyToMany)?.length >= 2 - model.relationships = values.reduce( - (relationships, relationship) => { - return [...relationships, ...relationship.getDefinitions(model.name)] - }, - [] - ) - + model.relationships = relationships.filter((relation) => relation.modelName === model.name) model.imports = importManager.extract(model) } return models.filter((model) => !model.isPivotTable) } + /** + * convert tables into model definitions + * @param tables + * @returns + */ static #getModelsFromTables(tables: TableSchema[]) { return tables.map((table) => { const columns = table.columns.map((column) => new ModelColumn(column)) diff --git a/src/model/relationship.ts b/src/model/relationship.ts deleted file mode 100644 index 68a8169..0000000 --- a/src/model/relationship.ts +++ /dev/null @@ -1,159 +0,0 @@ -import string from '@adonisjs/core/helpers/string' -import RelationshipTypes from '../enums/relationship_types.js' -import type Model from './index.js' -import type ModelColumn from './column.js' - -export type ModelRelationshipInfo = { - type: RelationshipTypes - modelName: string - modelColumn: string - tableName: string - tableColumn: string - relatedModelName: string -} - -export type ModelRelationshipDefinition = { - type: RelationshipTypes - relatedModelName: string - decorator: string - property: string -} - -export default class ModelRelationship { - declare foreignKey: string - declare parent: ModelRelationshipInfo - declare child: ModelRelationshipInfo - - constructor(type: RelationshipTypes, column: ModelColumn, models: Model[]) { - const isBelongsTo = type === RelationshipTypes.BELONGS_TO - const local = models.find((model) => model.tableName === column.tableName) - const foreign = models.find((model) => model.tableName === column.foreignKeyTable) - - const [parentModel, childModel] = isBelongsTo ? [foreign, local] : [local, foreign] - const [parentColumn, childColumn] = isBelongsTo - ? [column.foreignKeyColumn, column.columnName] - : [column.columnName, column.foreignKeyColumn] - - this.parent = { - type: type, - tableName: parentModel!.tableName, - tableColumn: parentColumn!, - modelName: parentModel!.name, - modelColumn: this.#getModelColumn(parentModel!, parentColumn!), - relatedModelName: childModel!.name - } - - this.child = { - type: type, - tableName: childModel!.tableName, - tableColumn: childColumn!, - modelName: childModel!.name, - modelColumn: this.#getModelColumn(childModel!, childColumn!), - relatedModelName: parentModel!.name - } - - this.foreignKey = `${this.parent.tableName}.${this.parent.tableColumn}_${this.child.tableName}.${this.child.tableColumn}` - - 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: ModelRelationshipDefinition[] = [] - - if (modelName === this.parent.modelName) { - definitions.push(this.#getDefinition(this.parent)) - } - - if (modelName === this.child.modelName) { - definitions.push(this.#getDefinition(this.child)) - } - - return definitions - } - - #getDefinition(info: ModelRelationshipInfo): ModelRelationshipDefinition { - let propertyName = string.camelCase(info.relatedModelName) - - switch (info.type) { - case RelationshipTypes.HAS_MANY: - case RelationshipTypes.MANY_TO_MANY: - propertyName = string.plural(propertyName) - break - } - - return { - type: info.type, - relatedModelName: info.relatedModelName, - decorator: `@${info.type}(() => ${info.relatedModelName})`, - property: `declare ${propertyName}: ${string.pascalCase(info.type)}`, - } - } - - #getModelColumn(model: Model, tableColumnName: string) { - return model.columns.find(({ columnName }) => columnName === tableColumnName)!.name - } - - #setTypes(type: RelationshipTypes, column: ModelColumn, models: Model[]) { - const tableNamesSingular = models - .filter((model) => model.tableName !== column.tableName) - .map((model) => string.singular(model.tableName)) - - const tableNameSingular = string.singular(column.tableName) - const startsWithTable = tableNamesSingular.find((name) => tableNameSingular.startsWith(name)) - const endsWithTable = tableNamesSingular.find((name) => tableNameSingular.endsWith(name)) - const pivotName = `${startsWithTable}_${endsWithTable}` - - // if start & end are both tables and their joined values match the current table - // then, assume it's a many-to-many - if (tableNameSingular === pivotName || type === RelationshipTypes.MANY_TO_MANY) { - this.child.type = RelationshipTypes.MANY_TO_MANY - this.parent.type = RelationshipTypes.MANY_TO_MANY - return - } - - // if relation is belongs to, assume the other side is has many - // note: the difference between has one & has many cannot be determined - // so we pick the more common and move on - if (type === RelationshipTypes.BELONGS_TO) { - this.child.type = type - this.parent.type = RelationshipTypes.HAS_MANY - return - } - - // otherwise, child is likely belongs to - this.parent.type = type - this.child.type = RelationshipTypes.BELONGS_TO - } - - static parse(models: Model[]) { - const relationships = new Map>() - - models.map((model) => { - model.columns.map((column) => { - const relationship = column.getRelationship(models) - - if (!relationship) return - - const parent = relationships.get(relationship.parent.modelName) ?? new Map() - - parent.set(relationship.foreignKey, relationship) - relationships.set(relationship.parent.modelName, parent) - - if (relationship.parent.modelName === relationship.child.modelName) return - - const child = relationships.get(relationship.child.modelName) ?? new Map() - - child.set(relationship.foreignKey, relationship) - relationships.set(relationship.child.modelName, child) - }) - }) - - return relationships - } -} diff --git a/stubs/generate/model.stub b/stubs/generate/model.stub index 49f39c4..905a410 100644 --- a/stubs/generate/model.stub +++ b/stubs/generate/model.stub @@ -3,7 +3,7 @@ {{ /each }} export default class {{ model.name }} extends BaseModel {{ '{' }}{{ #each model.columns as column }} {{ column.getDecorator() }} - declare {{ column.name }}: {{ column.type }} + declare {{ column.name }}: {{ column.type }}{{ column.isNullable ? ' | null' : '' }} {{ /each }}{{ #each model.relationships as relationship, index }} {{ relationship.decorator }} {{{ relationship.property }}}{{ index + 1 < model.relationships.length ? '\n' : '' }} @@ -15,3 +15,4 @@ export default class {{ model.name }} extends BaseModel {{ '{' }}{{ #each model. to: app.modelsPath(model.fileName) }) }}} +{{ '\n' }}