diff --git a/.changeset/wet-zoos-add.md b/.changeset/wet-zoos-add.md new file mode 100644 index 000000000..7069b4a82 --- /dev/null +++ b/.changeset/wet-zoos-add.md @@ -0,0 +1,5 @@ +--- +"@liam-hq/db-structure": patch +--- + +✨ Enhance the schema.rb parser to support Constraints diff --git a/frontend/packages/db-structure/src/parser/__snapshots__/index.test.ts.snap b/frontend/packages/db-structure/src/parser/__snapshots__/index.test.ts.snap index d396994e8..6658765a6 100644 --- a/frontend/packages/db-structure/src/parser/__snapshots__/index.test.ts.snap +++ b/frontend/packages/db-structure/src/parser/__snapshots__/index.test.ts.snap @@ -306,7 +306,31 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Stores comments on tasks, enabling discussions or updates.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + "fk_comments_task_id": { + "columnName": "task_id", + "deleteConstraint": "CASCADE", + "name": "fk_comments_task_id", + "targetColumnName": "id", + "targetTableName": "tasks", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + "fk_comments_user_id": { + "columnName": "user_id", + "deleteConstraint": "CASCADE", + "name": "fk_comments_user_id", + "targetColumnName": "id", + "targetTableName": "users", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + }, "indexes": { "index_comments_on_task_id": { "columns": [ @@ -361,7 +385,13 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Represents organizations using the system. Each company is a top-level entity that owns departments, users, and projects.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + }, "indexes": {}, "name": "companies", }, @@ -399,7 +429,22 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Represents departments within a company, organizing users into functional groups.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + "fk_departments_company_id": { + "columnName": "company_id", + "deleteConstraint": "CASCADE", + "name": "fk_departments_company_id", + "targetColumnName": "id", + "targetTableName": "companies", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + }, "indexes": { "index_departments_on_company_id": { "columns": [ @@ -446,7 +491,36 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Associates users with projects they are assigned to work on.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + "UNIQUE_user_id": { + "columnName": "user_id", + "name": "UNIQUE_user_id", + "type": "UNIQUE", + }, + "fk_project_assignments_project_id": { + "columnName": "project_id", + "deleteConstraint": "CASCADE", + "name": "fk_project_assignments_project_id", + "targetColumnName": "id", + "targetTableName": "projects", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + "fk_project_assignments_user_id": { + "columnName": "user_id", + "deleteConstraint": "CASCADE", + "name": "fk_project_assignments_user_id", + "targetColumnName": "id", + "targetTableName": "users", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + }, "indexes": { "index_project_assignments_on_user_id_and_project_id": { "columns": [ @@ -504,7 +578,22 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Represents projects managed within a company. Projects are linked to tasks and users.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + "fk_projects_company_id": { + "columnName": "company_id", + "deleteConstraint": "CASCADE", + "name": "fk_projects_company_id", + "targetColumnName": "id", + "targetTableName": "companies", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + }, "indexes": { "index_projects_on_company_id": { "columns": [ @@ -551,7 +640,13 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Defines roles that can be assigned to users, such as 'Admin' or 'Manager'.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + }, "indexes": {}, "name": "roles", }, @@ -629,7 +724,31 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Represents tasks within a project, assigned to users with deadlines and statuses.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + "fk_tasks_assigned_user_id": { + "columnName": "assigned_user_id", + "deleteConstraint": "RESTRICT", + "name": "fk_tasks_assigned_user_id", + "targetColumnName": "id", + "targetTableName": "users", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + "fk_tasks_project_id": { + "columnName": "project_id", + "deleteConstraint": "CASCADE", + "name": "fk_tasks_project_id", + "targetColumnName": "id", + "targetTableName": "projects", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + }, "indexes": { "index_tasks_on_assigned_user_id": { "columns": [ @@ -714,7 +833,31 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Tracks time spent by users on tasks for reporting or billing purposes.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + "fk_timesheets_task_id": { + "columnName": "task_id", + "deleteConstraint": "CASCADE", + "name": "fk_timesheets_task_id", + "targetColumnName": "id", + "targetTableName": "tasks", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + "fk_timesheets_user_id": { + "columnName": "user_id", + "deleteConstraint": "CASCADE", + "name": "fk_timesheets_user_id", + "targetColumnName": "id", + "targetTableName": "users", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + }, "indexes": { "index_timesheets_on_task_id": { "columns": [ @@ -769,7 +912,36 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Associates users with roles to define their permissions within the company.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + "UNIQUE_user_id": { + "columnName": "user_id", + "name": "UNIQUE_user_id", + "type": "UNIQUE", + }, + "fk_user_roles_role_id": { + "columnName": "role_id", + "deleteConstraint": "CASCADE", + "name": "fk_user_roles_role_id", + "targetColumnName": "id", + "targetTableName": "roles", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + "fk_user_roles_user_id": { + "columnName": "user_id", + "deleteConstraint": "CASCADE", + "name": "fk_user_roles_user_id", + "targetColumnName": "id", + "targetTableName": "users", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + }, "indexes": { "index_user_roles_on_user_id_and_role_id": { "columns": [ @@ -867,7 +1039,31 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": "Represents employees or members of a company, who are assigned roles and tasks.", - "constraints": {}, + "constraints": { + "PRIMARY_id": { + "columnName": "id", + "name": "PRIMARY_id", + "type": "PRIMARY KEY", + }, + "fk_users_company_id": { + "columnName": "company_id", + "deleteConstraint": "CASCADE", + "name": "fk_users_company_id", + "targetColumnName": "id", + "targetTableName": "companies", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + "fk_users_department_id": { + "columnName": "department_id", + "deleteConstraint": "CASCADE", + "name": "fk_users_department_id", + "targetColumnName": "id", + "targetTableName": "departments", + "type": "FOREIGN KEY", + "updateConstraint": "RESTRICT", + }, + }, "indexes": { "index_users_on_company_id": { "columns": [ diff --git a/frontend/packages/db-structure/src/parser/schemarb/index.test.ts b/frontend/packages/db-structure/src/parser/schemarb/index.test.ts index e78d390da..e24ee78cf 100644 --- a/frontend/packages/db-structure/src/parser/schemarb/index.test.ts +++ b/frontend/packages/db-structure/src/parser/schemarb/index.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest' import type { Table } from '../../schema/index.js' -import { aColumn, aSchema, aTable } from '../../schema/index.js' +import { + aCheckConstraint, + aColumn, + aForeignKeyConstraint, + aPrimaryKeyConstraint, + aRelationship, + aSchema, + aTable, + aUniqueConstraint, + anIndex, +} from '../../schema/index.js' import { UnsupportedTokenError, processor } from './index.js' import { createParserTestCases } from '../__tests__/index.js' @@ -25,6 +35,14 @@ describe(processor, () => { ...override?.indexes, }, comment: override?.comment ?? null, + constraints: { + PRIMARY_id: { + type: 'PRIMARY KEY', + name: 'PRIMARY_id', + columnName: 'id', + }, + ...override?.constraints, + }, }), }, }) @@ -210,46 +228,150 @@ describe(processor, () => { end `) - expect(value).toEqual(parserTestCases['index (unique: true)']('')) + const expected = aSchema({ + tables: { + users: aTable({ + name: 'users', + columns: { + id: aColumn({ + name: 'id', + type: 'bigserial', + primary: true, + notNull: true, + unique: true, + }), + email: aColumn({ + name: 'email', + type: 'varchar', + }), + }, + indexes: { + index_users_on_email: anIndex({ + name: 'index_users_on_email', + columns: ['email'], + unique: true, + }), + }, + constraints: { + PRIMARY_id: aPrimaryKeyConstraint({ + name: 'PRIMARY_id', + columnName: 'id', + }), + UNIQUE_email: aUniqueConstraint({ + name: 'UNIQUE_email', + columnName: 'email', + }), + }, + }), + }, + }) + + expect(value).toEqual(expected) }) - it('foreign key (one-to-many)', async () => { + it('foreign key', async () => { const keyName = 'fk_posts_user_id' const { value } = await processor(/* Ruby */ ` + create_table "posts" do |t| + t.bigint "user_id" + end + add_foreign_key "posts", "users", column: "user_id", name: "${keyName}" `) - expect(value.relationships).toEqual( - parserTestCases['foreign key (one-to-many)'](keyName), - ) + expect(value.relationships).toEqual({ + fk_posts_user_id: aRelationship({ + name: 'fk_posts_user_id', + foreignTableName: 'posts', + foreignColumnName: 'user_id', + primaryTableName: 'users', + primaryColumnName: 'id', + }), + }) + expect(value.tables['posts']?.constraints).toEqual({ + PRIMARY_id: aPrimaryKeyConstraint({ + name: 'PRIMARY_id', + columnName: 'id', + }), + fk_posts_user_id: aForeignKeyConstraint({ + name: 'fk_posts_user_id', + columnName: 'user_id', + targetTableName: 'users', + targetColumnName: 'id', + }), + }) }) - it('foreign key with omit column name', async () => { - const keyName = 'fk_posts_user_id' + it('foreign key (without explicit constraint name and column options)', async () => { const { value } = await processor(/* Ruby */ ` - add_foreign_key "posts", "users", name: "${keyName}" + create_table "posts" do |t| + t.bigint "user_id" + end + + add_foreign_key "posts", "users" `) - expect(value.relationships).toEqual( - parserTestCases['foreign key (one-to-many)'](keyName), - ) + expect(value.relationships).toEqual({ + users_id_to_posts_user_id: aRelationship({ + name: 'users_id_to_posts_user_id', + foreignTableName: 'posts', + foreignColumnName: 'user_id', + primaryTableName: 'users', + primaryColumnName: 'id', + }), + }) + expect(value.tables['posts']?.constraints).toEqual({ + PRIMARY_id: aPrimaryKeyConstraint({ + name: 'PRIMARY_id', + columnName: 'id', + }), + users_id_to_posts_user_id: aForeignKeyConstraint({ + name: 'users_id_to_posts_user_id', + columnName: 'user_id', + targetTableName: 'users', + targetColumnName: 'id', + }), + }) }) - it('foreign key with omit key name', async () => { - const { value } = await processor(/* Ruby */ ` + describe('foreign key cardinality', () => { + it('foreign key (one-to-many)', async () => { + const keyName = 'fk_posts_user_id' + const { value } = await processor(/* Ruby */ ` + add_foreign_key "posts", "users", column: "user_id", name: "${keyName}" + `) + + expect(value.relationships).toEqual( + parserTestCases['foreign key (one-to-many)'](keyName), + ) + }) + + it('foreign key with omit column name', async () => { + const keyName = 'fk_posts_user_id' + const { value } = await processor(/* Ruby */ ` + add_foreign_key "posts", "users", name: "${keyName}" + `) + + expect(value.relationships).toEqual( + parserTestCases['foreign key (one-to-many)'](keyName), + ) + }) + + it('foreign key with omit key name', async () => { + const { value } = await processor(/* Ruby */ ` add_foreign_key "posts", "users", column: "user_id" `) - expect(value.relationships).toEqual( - parserTestCases['foreign key (one-to-many)']( - 'users_id_to_posts_user_id', - ), - ) - }) + expect(value.relationships).toEqual( + parserTestCases['foreign key (one-to-many)']( + 'users_id_to_posts_user_id', + ), + ) + }) - it('foreign key (one-to-one)', async () => { - const keyName = 'users_id_to_posts_user_id' - const { value } = await processor(/* Ruby */ ` + it('foreign key (one-to-one)', async () => { + const keyName = 'users_id_to_posts_user_id' + const { value } = await processor(/* Ruby */ ` create_table "posts" do |t| t.bigint "user_id", unique: true end @@ -257,19 +379,70 @@ describe(processor, () => { add_foreign_key "posts", "users", column: "user_id" `) - expect(value.relationships).toEqual( - parserTestCases['foreign key (one-to-one)'](keyName), - ) + expect(value.relationships).toEqual( + parserTestCases['foreign key (one-to-one)'](keyName), + ) + }) }) it('foreign keys with action', async () => { const { value } = await processor(/* Ruby */ ` + create_table "posts" do |t| + t.bigint "user_id" + end + add_foreign_key "posts", "users", column: "user_id", name: "fk_posts_user_id", on_update: :restrict, on_delete: :cascade `) - expect(value.relationships).toEqual( - parserTestCases['foreign key with action'], - ) + expect(value.relationships).toEqual({ + fk_posts_user_id: aRelationship({ + name: 'fk_posts_user_id', + foreignTableName: 'posts', + foreignColumnName: 'user_id', + primaryTableName: 'users', + primaryColumnName: 'id', + cardinality: 'ONE_TO_MANY', + updateConstraint: 'RESTRICT', + deleteConstraint: 'CASCADE', + }), + }) + expect(value.tables['posts']?.constraints).toEqual({ + PRIMARY_id: aPrimaryKeyConstraint({ + type: 'PRIMARY KEY', + name: 'PRIMARY_id', + columnName: 'id', + }), + fk_posts_user_id: aForeignKeyConstraint({ + type: 'FOREIGN KEY', + name: 'fk_posts_user_id', + columnName: 'user_id', + targetTableName: 'users', + targetColumnName: 'id', + updateConstraint: 'RESTRICT', + deleteConstraint: 'CASCADE', + }), + }) + }) + + it('check constraint', async () => { + const { value } = await processor(/* Ruby */ ` + create_table "users" do |t| + t.integer "age" + end + + add_check_constraint "users", "age >= 20 and age < 20", name: "age_range_check" + `) + + expect(value.tables['users']?.constraints).toEqual({ + PRIMARY_id: aPrimaryKeyConstraint({ + name: 'PRIMARY_id', + columnName: 'id', + }), + age_range_check: aCheckConstraint({ + name: 'age_range_check', + detail: 'age >= 20 and age < 20', + }), + }) }) }) diff --git a/frontend/packages/db-structure/src/parser/schemarb/parser.ts b/frontend/packages/db-structure/src/parser/schemarb/parser.ts index c27d070db..16e099ef0 100644 --- a/frontend/packages/db-structure/src/parser/schemarb/parser.ts +++ b/frontend/packages/db-structure/src/parser/schemarb/parser.ts @@ -15,17 +15,30 @@ import { } from '@ruby/prism' import { type Result, err, ok } from 'neverthrow' import type { + CheckConstraint, Column, Columns, + Constraint, + Constraints, + ForeignKeyConstraint, ForeignKeyConstraintReferenceOption, Index, Indexes, + PrimaryKeyConstraint, Relationship, Schema, Table, Tables, + UniqueConstraint, +} from '../../schema/index.js' +import { + aCheckConstraint, + aColumn, + aForeignKeyConstraint, + aRelationship, + aTable, + anIndex, } from '../../schema/index.js' -import { aColumn, aRelationship, aTable, anIndex } from '../../schema/index.js' import { type ProcessError, UnexpectedTokenWarningError, @@ -83,7 +96,9 @@ function extractTableComment(argNodes: Node[]): string | null { return null } -function extractIdColumn(argNodes: Node[]): Column | null { +function extractIdColumnAndConstraint( + argNodes: Node[], +): [Column, PrimaryKeyConstraint] | [null, null] { const keywordHash = argNodes.find((node) => node instanceof KeywordHashNode) const idColumn = aColumn({ @@ -93,6 +108,11 @@ function extractIdColumn(argNodes: Node[]): Column | null { primary: true, unique: true, }) + const idPrimaryKeyConstraint: PrimaryKeyConstraint = { + type: 'PRIMARY KEY', + name: 'PRIMARY_id', + columnName: 'id', + } if (keywordHash) { const idAssoc = keywordHash.elements.find( @@ -103,26 +123,29 @@ function extractIdColumn(argNodes: Node[]): Column | null { ) if (idAssoc && idAssoc instanceof AssocNode) { - if (idAssoc.value instanceof FalseNode) return null + if (idAssoc.value instanceof FalseNode) return [null, null] if ( idAssoc.value instanceof StringNode || idAssoc.value instanceof SymbolNode ) idColumn.type = idAssoc.value.unescaped.value - return idColumn + return [idColumn, idPrimaryKeyConstraint] } } // Since 5.1 PostgreSQL adapter uses bigserial type for primary key in default // See:https://github.com/rails/rails/blob/v8.0.0/activerecord/lib/active_record/migration/compatibility.rb#L377 idColumn.type = 'bigserial' - return idColumn + return [idColumn, idPrimaryKeyConstraint] } -function extractTableDetails(blockNodes: Node[]): [Column[], Index[]] { +function extractTableDetails( + blockNodes: Node[], +): [Column[], Index[], Constraint[]] { const columns: Column[] = [] const indexes: Index[] = [] + const constraints: Constraint[] = [] for (const blockNode of blockNodes) { if (blockNode instanceof StatementsNode) { @@ -135,6 +158,14 @@ function extractTableDetails(blockNodes: Node[]): [Column[], Index[]] { if (node.name === 'index') { const index = extractIndexDetails(node) indexes.push(index) + if (index.unique && index.columns[0]) { + const uniqueConstraint: UniqueConstraint = { + type: 'UNIQUE', + name: `UNIQUE_${index.columns[0]}`, + columnName: index.columns[0], + } + constraints.push(uniqueConstraint) + } continue } @@ -145,7 +176,7 @@ function extractTableDetails(blockNodes: Node[]): [Column[], Index[]] { } } - return [columns, indexes] + return [columns, indexes, constraints] } function extractColumnDetails(node: CallNode): Column { @@ -278,6 +309,51 @@ function extractRelationshipTableNames( return ok([primaryTableName, foreignTableName]) } +function extractCheckConstraint( + argNodes: Node[], +): Result< + { tableName: string; constraint: CheckConstraint }, + UnexpectedTokenWarningError +> { + const stringNodes = argNodes.filter((node) => node instanceof StringNode) + if (stringNodes.length !== 2) { + return err( + new UnexpectedTokenWarningError( + 'Check constraint must have one table name and its detail', + ), + ) + } + + const [tableName, detail] = stringNodes.map((node): string => { + if (node instanceof StringNode) return node.unescaped.value + return '' + }) as [string, string] + + const constraint = aCheckConstraint({ + detail, + }) + + for (const node of argNodes) { + if (node instanceof KeywordHashNode) { + for (const argElement of node.elements) { + if (!(argElement instanceof AssocNode)) continue + // @ts-expect-error: unescaped is defined as string but it is actually object + const key = argElement.key.unescaped.value + const value = argElement.value + + switch (key) { + case 'name': + if (value instanceof StringNode || value instanceof SymbolNode) { + constraint.name = value.unescaped.value + } + } + } + } + } + + return ok({ tableName, constraint }) +} + function normalizeConstraintName( constraint: string, ): ForeignKeyConstraintReferenceOption { @@ -298,6 +374,7 @@ function normalizeConstraintName( function extractForeignKeyOptions( argNodes: Node[], relation: Relationship, + foreignKeyConstraint: ForeignKeyConstraint, ): void { for (const argNode of argNodes) { if (argNode instanceof KeywordHashNode) { @@ -311,25 +388,31 @@ function extractForeignKeyOptions( case 'column': if (value instanceof StringNode || value instanceof SymbolNode) { relation.foreignColumnName = value.unescaped.value + foreignKeyConstraint.columnName = value.unescaped.value } break case 'name': if (value instanceof StringNode || value instanceof SymbolNode) { relation.name = value.unescaped.value + foreignKeyConstraint.name = value.unescaped.value } break case 'on_update': if (value instanceof SymbolNode) { - relation.updateConstraint = normalizeConstraintName( + const updateConstraint = normalizeConstraintName( value.unescaped.value, ) + relation.updateConstraint = updateConstraint + foreignKeyConstraint.updateConstraint = updateConstraint } break case 'on_delete': if (value instanceof SymbolNode) { - relation.deleteConstraint = normalizeConstraintName( + const deleteConstraint = normalizeConstraintName( value.unescaped.value, ) + relation.deleteConstraint = deleteConstraint + foreignKeyConstraint.deleteConstraint = deleteConstraint } break } @@ -339,16 +422,20 @@ function extractForeignKeyOptions( // ref: https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_foreign_key if (relation.foreignColumnName === '') { - relation.foreignColumnName = `${singularize(relation.primaryTableName)}_id` + const columnName = `${singularize(relation.primaryTableName)}_id` + relation.foreignColumnName = columnName + foreignKeyConstraint.columnName = columnName } if (relation.name === '') { - relation.name = defaultRelationshipName( + const relationshipName = defaultRelationshipName( relation.primaryTableName, relation.primaryColumnName, relation.foreignTableName, relation.foreignColumnName, ) + relation.name = relationshipName + foreignKeyConstraint.name = relationshipName } } @@ -396,15 +483,21 @@ class SchemaFinder extends Visitor { const columns: Column[] = [] const indexes: Index[] = [] + const constraints: Constraint[] = [] - const idColumn = extractIdColumn(argNodes) - if (idColumn) columns.push(idColumn) + const [idColumn, idConstraint] = extractIdColumnAndConstraint(argNodes) + if (idColumn) { + columns.push(idColumn) + constraints.push(idConstraint) + } const blockNodes = node.block?.compactChildNodes() || [] - const [extractColumns, extractIndexes] = extractTableDetails(blockNodes) + const [extractColumns, extractIndexes, extractConstraints] = + extractTableDetails(blockNodes) columns.push(...extractColumns) indexes.push(...extractIndexes) + constraints.push(...extractConstraints) table.columns = columns.reduce((acc, column) => { acc[column.name] = column @@ -416,6 +509,11 @@ class SchemaFinder extends Visitor { return acc }, {} as Indexes) + table.constraints = constraints.reduce((acc, constraint) => { + acc[constraint.name] = constraint + return acc + }, {} as Constraints) + this.tables.push(table) } @@ -435,15 +533,42 @@ class SchemaFinder extends Visitor { primaryColumnName: 'id', foreignTableName: foreignTableName, }) + const foreignKeyConstraint = aForeignKeyConstraint({ + targetTableName: primaryTableName, + targetColumnName: 'id', + }) - extractForeignKeyOptions(argNodes, relationship) + extractForeignKeyOptions(argNodes, relationship, foreignKeyConstraint) this.relationships.push(relationship) + const foreignTable = this.tables.find( + (table) => table.name === foreignTableName, + ) + if (foreignTable) { + foreignTable.constraints[foreignKeyConstraint.name] = foreignKeyConstraint + } + } + + handleAddCheckConstraint(node: CallNode): void { + const argNodes = node.arguments_?.compactChildNodes() || [] + + const constraintResult = extractCheckConstraint(argNodes) + if (constraintResult.isErr()) { + this.errors.push(constraintResult.error) + return + } + const { tableName, constraint } = constraintResult.value + const table = this.tables.find((table) => table.name === tableName) + if (table) { + table.constraints[constraint.name] = constraint + } } override visitCallNode(node: CallNode): void { if (node.name === 'create_table') this.handleCreateTable(node) if (node.name === 'add_foreign_key') this.handleAddForeignKey(node) + if (node.name === 'add_check_constraint') + this.handleAddCheckConstraint(node) super.visitCallNode(node) } diff --git a/frontend/packages/db-structure/src/schema/factories.ts b/frontend/packages/db-structure/src/schema/factories.ts index 42ad6a933..c50b5f012 100644 --- a/frontend/packages/db-structure/src/schema/factories.ts +++ b/frontend/packages/db-structure/src/schema/factories.ts @@ -1,10 +1,14 @@ import type { + CheckConstraint, Column, + ForeignKeyConstraint, Index, + PrimaryKeyConstraint, Relationship, Schema, Table, Tables, + UniqueConstraint, } from './schema.js' export const aColumn = (override?: Partial): Column => ({ @@ -42,6 +46,46 @@ export const anIndex = (override?: Partial): Index => ({ ...override, }) +export const aPrimaryKeyConstraint = ( + override?: Partial, +): PrimaryKeyConstraint => ({ + type: 'PRIMARY KEY', + name: '', + columnName: '', + ...override, +}) + +export const aForeignKeyConstraint = ( + override?: Partial, +): ForeignKeyConstraint => ({ + type: 'FOREIGN KEY', + name: '', + columnName: '', + targetTableName: '', + targetColumnName: '', + updateConstraint: 'NO_ACTION', + deleteConstraint: 'NO_ACTION', + ...override, +}) + +export const aUniqueConstraint = ( + override?: Partial, +): UniqueConstraint => ({ + type: 'UNIQUE', + name: '', + columnName: '', + ...override, +}) + +export const aCheckConstraint = ( + override?: Partial, +): CheckConstraint => ({ + type: 'CHECK', + name: '', + detail: '', + ...override, +}) + export const aRelationship = ( override?: Partial, ): Relationship => ({ diff --git a/frontend/packages/db-structure/src/schema/index.ts b/frontend/packages/db-structure/src/schema/index.ts index 27a1f0c08..9af20425a 100644 --- a/frontend/packages/db-structure/src/schema/index.ts +++ b/frontend/packages/db-structure/src/schema/index.ts @@ -13,6 +13,7 @@ export type { Relationships, Index, Indexes, + Constraint, Constraints, PrimaryKeyConstraint, ForeignKeyConstraint, @@ -27,7 +28,11 @@ export { aTable, aSchema, anIndex, + aPrimaryKeyConstraint, aRelationship, + aUniqueConstraint, + aForeignKeyConstraint, + aCheckConstraint, } from './factories.js' export { overrideSchema,