Skip to content

Commit

Permalink
refactor(relationships): refactoring relationship building into an ex…
Browse files Browse the repository at this point in the history
…tractor so that we can gather both sides of the many-to-many
  • Loading branch information
tomgobich committed Sep 18, 2024
1 parent 7179d48 commit 7074065
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 198 deletions.
2 changes: 1 addition & 1 deletion commands/generate_models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
12 changes: 6 additions & 6 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export type TableSchema = {

const ignoreTables = ['adonis_schema', 'adonis_schema_versions']

/**
* parse schema information from the provided database connection
* @param db

Check failure on line 15 in src/db/schema.ts

View workflow job for this annotation

GitHub Actions / windows (20.10.0)

Delete `·`

Check failure on line 15 in src/db/schema.ts

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest, 20.10.0)

Delete `·`
* @returns

Check failure on line 16 in src/db/schema.ts

View workflow job for this annotation

GitHub Actions / windows (20.10.0)

Delete `·`

Check failure on line 16 in src/db/schema.ts

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest, 20.10.0)

Delete `·`
*/
export async function schema(db: Database) {
const knex = db.connection().getWriteClient()
const inspector = schemaInspector(knex)
Expand All @@ -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)
}
29 changes: 28 additions & 1 deletion src/extractors/import_extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ class ModelImport {
this.isType = isType ?? this.isType
}

/**
* converts model imports into import strings, grouped by import path
* @param imports
* @returns

Check failure on line 21 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / windows (20.10.0)

Delete `·`

Check failure on line 21 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest, 20.10.0)

Delete `·`
*/
static getStatements(imports: ModelImport[]) {
const groups = this.#getNamespaceGroups(imports)

Expand All @@ -33,6 +38,11 @@ class ModelImport {
})
}

/**
* group imports by their path
* @param imports
* @returns

Check failure on line 44 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / windows (20.10.0)

Delete `·`

Check failure on line 44 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest, 20.10.0)

Delete `·`
*/
static #getNamespaceGroups(imports: ModelImport[]) {
return imports.reduce<Record<string, ModelImport[]>>((groups, imp) => {
const group = groups[imp.namespace] || []
Expand All @@ -49,6 +59,10 @@ class ModelImport {
export default class ModelImportManager {
#imports = new Map<string, ModelImport>()

/**
* 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)
Expand All @@ -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

Check failure on line 88 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / windows (20.10.0)

Delete `·`

Check failure on line 88 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest, 20.10.0)

Delete `·`
* @returns

Check failure on line 89 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / windows (20.10.0)

Delete `·`

Check failure on line 89 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest, 20.10.0)

Delete `·`
*/
extract(model: Model) {
this.add(new ModelImport('BaseModel', '@adonisjs/lucid/orm'))
this.add(new ModelImport('column', '@adonisjs/lucid/orm'))
Expand All @@ -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))

Check failure on line 103 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / windows (20.10.0)

Replace `new·ModelImport(definition.relatedModelName,·`./${string.snakeCase(definition.relatedModelName)}.js`,·true)` with `␍⏎··········new·ModelImport(␍⏎············definition.relatedModelName,␍⏎············`./${string.snakeCase(definition.relatedModelName)}.js`,␍⏎············true␍⏎··········)␍⏎········`

Check failure on line 103 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest, 20.10.0)

Replace `new·ModelImport(definition.relatedModelName,·`./${string.snakeCase(definition.relatedModelName)}.js`,·true)` with `⏎··········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))

Check failure on line 107 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / windows (20.10.0)

Replace `new·ModelImport(string.pascalCase(definition.type),·'@adonisjs/lucid/types/relations',·false,·true)` with `␍⏎········new·ModelImport(␍⏎··········string.pascalCase(definition.type),␍⏎··········'@adonisjs/lucid/types/relations',␍⏎··········false,␍⏎··········true␍⏎········)␍⏎······`

Check failure on line 107 in src/extractors/import_extractor.ts

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest, 20.10.0)

Replace `new·ModelImport(string.pascalCase(definition.type),·'@adonisjs/lucid/types/relations',·false,·true)` with `⏎········new·ModelImport(⏎··········string.pascalCase(definition.type),⏎··········'@adonisjs/lucid/types/relations',⏎··········false,⏎··········true⏎········)⏎······`
})
Expand Down
238 changes: 238 additions & 0 deletions src/extractors/relationship_extractor.ts
Original file line number Diff line number Diff line change
@@ -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<keyof DecoratorOptions>

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)}<typeof ${this.relatedModelName}>`
}
}
}

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
}
}
5 changes: 5 additions & 0 deletions src/extractors/type_extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 4 additions & 15 deletions src/model/column.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 })'
Expand All @@ -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)
}
}
Loading

0 comments on commit 7074065

Please sign in to comment.