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 9a2d217cd..225a1c719 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 @@ -41,7 +41,7 @@ exports[`parse > should parse postgresql to JSON correctly 1`] = ` }, }, "comment": null, - "indices": [], + "indices": {}, "name": "posts", }, "users": { @@ -92,7 +92,16 @@ exports[`parse > should parse postgresql to JSON correctly 1`] = ` }, }, "comment": null, - "indices": [], + "indices": { + "index_users_on_id_and_email": { + "columns": [ + "id", + "email", + ], + "name": "index_users_on_id_and_email", + "unique": true, + }, + }, "name": "users", }, }, @@ -184,7 +193,15 @@ exports[`parse > should parse schema.rb to JSON correctly 1`] = ` }, }, "comment": null, - "indices": [], + "indices": { + "index_users_on_company_id": { + "columns": [ + "company_id", + ], + "name": "index_users_on_company_id", + "unique": false, + }, + }, "name": "users", }, }, 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 1d25510ae..b9adabb1c 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,6 @@ import { describe, expect, it } from 'vitest' import type { Table } from '../../schema/index.js' -import { aColumn, aDBStructure, aTable } from '../../schema/index.js' +import { aColumn, aDBStructure, aTable, anIndex } from '../../schema/index.js' import { processor } from './index.js' describe(processor, () => { @@ -20,6 +20,9 @@ describe(processor, () => { }), ...override?.columns, }, + indices: { + ...override?.indices, + }, }), }, }) @@ -166,5 +169,25 @@ describe(processor, () => { expect(result).toEqual(expected) }) + + it('index', async () => { + const result = await processor(/* Ruby */ ` + create_table "users" do |t| + t.index [ "id", "email" ], name: "index_users_on_id_and_email", unique: true + end + `) + + const expected = userTable({ + indices: { + index_users_on_id_and_email: anIndex({ + name: 'index_users_on_id_and_email', + unique: true, + columns: ['id', 'email'], + }), + }, + }) + + expect(result).toEqual(expected) + }) }) }) diff --git a/frontend/packages/db-structure/src/parser/schemarb/parser.ts b/frontend/packages/db-structure/src/parser/schemarb/parser.ts index d168da3f3..5b6ec8c36 100644 --- a/frontend/packages/db-structure/src/parser/schemarb/parser.ts +++ b/frontend/packages/db-structure/src/parser/schemarb/parser.ts @@ -1,4 +1,5 @@ import { + ArrayNode, AssocNode, CallNode, FalseNode, @@ -17,10 +18,12 @@ import type { Column, Columns, DBStructure, + Index, + Indices, Table, Tables, } from '../../schema/index.js' -import { aColumn, aTable } from '../../schema/index.js' +import { aColumn, aTable, anIndex } from '../../schema/index.js' import type { Processor } from '../types.js' function extractTableName(argNodes: Node[]): string { @@ -65,8 +68,9 @@ function extractIdColumn(argNodes: Node[]): Column | null { return idColumn } -function extractTableColumns(blockNodes: Node[]): Column[] { +function extractTableDetails(blockNodes: Node[]): [Column[], Index[]] { const columns: Column[] = [] + const indices: Index[] = [] for (const blockNode of blockNodes) { if (blockNode instanceof StatementsNode) { @@ -76,8 +80,11 @@ function extractTableColumns(blockNodes: Node[]): Column[] { node.receiver instanceof LocalVariableReadNode && node.receiver.name === 't' ) { - // TODO: Need to handle index - if (node.name === 'index') continue + if (node.name === 'index') { + const index = extractIndexDetails(node) + indices.push(index) + continue + } const column = extractColumnDetails(node) if (column.name) columns.push(column) @@ -86,7 +93,7 @@ function extractTableColumns(blockNodes: Node[]): Column[] { } } - return columns + return [columns, indices] } function extractColumnDetails(node: CallNode): Column { @@ -108,6 +115,31 @@ function extractColumnDetails(node: CallNode): Column { return column } +function extractIndexDetails(node: CallNode): Index { + const index = anIndex({ + name: '', + unique: false, + columns: [], + }) + + const argNodes = node.arguments_?.compactChildNodes() || [] + for (const argNode of argNodes) { + if (argNode instanceof ArrayNode) { + const argElemens = argNode.compactChildNodes() + for (const argElem of argElemens) { + if (argElem instanceof StringNode) { + // @ts-expect-error: unescaped is defined as string but it is actually object + index.columns.push(argElem.unescaped.value) + } + } + } else if (argNode instanceof KeywordHashNode) { + extractIndexOptions(argNode, index) + } + } + + return index +} + function extractColumnOptions(hashNode: KeywordHashNode, column: Column): void { for (const argElement of hashNode.elements) { if (!(argElement instanceof AssocNode)) continue @@ -129,6 +161,25 @@ function extractColumnOptions(hashNode: KeywordHashNode, column: Column): void { } } +function extractIndexOptions(hashNode: KeywordHashNode, index: Index): void { + for (const argElement of hashNode.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': + // @ts-expect-error: unescaped is defined as string but it is actually object + index.name = value.unescaped.value + break + case 'unique': + index.unique = value instanceof TrueNode + break + } + } +} + function extractDefaultValue( value: TrueNode | FalseNode | StringNode | IntegerNode, ): string | number | boolean | null { @@ -164,18 +215,27 @@ class DBStructureFinder extends Visitor { }) const columns: Column[] = [] + const indices: Index[] = [] const idColumn = extractIdColumn(argNodes) if (idColumn) columns.push(idColumn) const blockNodes = node.block?.compactChildNodes() || [] - columns.push(...extractTableColumns(blockNodes)) + const [extractColumns, extractIndices] = extractTableDetails(blockNodes) + + columns.push(...extractColumns) + indices.push(...extractIndices) table.columns = columns.reduce((acc, column) => { acc[column.name] = column return acc }, {} as Columns) + table.indices = indices.reduce((acc, index) => { + acc[index.name] = index + return acc + }, {} as Indices) + this.tables.push(table) } diff --git a/frontend/packages/db-structure/src/parser/sql/input/postgresql_schema1.in.sql b/frontend/packages/db-structure/src/parser/sql/input/postgresql_schema1.in.sql index 9d96fae68..d1d06ad53 100644 --- a/frontend/packages/db-structure/src/parser/sql/input/postgresql_schema1.in.sql +++ b/frontend/packages/db-structure/src/parser/sql/input/postgresql_schema1.in.sql @@ -9,3 +9,5 @@ CREATE TABLE posts ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ); + +CREATE UNIQUE INDEX index_users_on_id_and_email ON public.users USING btree (id, email); diff --git a/frontend/packages/db-structure/src/parser/sql/postgresql/converter.ts b/frontend/packages/db-structure/src/parser/sql/postgresql/converter.ts index 7c1fa99e9..7ededfd36 100644 --- a/frontend/packages/db-structure/src/parser/sql/postgresql/converter.ts +++ b/frontend/packages/db-structure/src/parser/sql/postgresql/converter.ts @@ -1,6 +1,7 @@ import type { Constraint, CreateStmt, + IndexStmt, Node, String as PgString, } from '@pgsql/types' @@ -35,138 +36,181 @@ export const convertToDBStructure = (ast: RawStmtWrapper[]): DBStructure => { return 'CreateStmt' in stmt } - if (!ast) { - return { - tables: {}, - relationships: {}, - } + function isIndexStmt(stmt: Node): stmt is { IndexStmt: IndexStmt } { + return 'IndexStmt' in stmt } - // pg-query-emscripten does not have types, so we need to define them ourselves - // @ts-expect-error - for (const statement of ast.parse_tree.stmts) { - if (statement?.stmt === undefined) continue - const stmt = statement.stmt - if (isCreateStmt(stmt)) { - const createStmt = stmt.CreateStmt - if (!createStmt || !createStmt.relation || !createStmt.tableElts) continue - - const tableName = createStmt.relation.relname - const columns: Columns = {} - for (const elt of createStmt.tableElts) { - if ('ColumnDef' in elt) { - const colDef = elt.ColumnDef - columns[colDef.colname || ''] = { - name: colDef.colname || '', - type: - colDef.typeName?.names - ?.filter(isStringNode) - .map((n) => n.String.sval) - .join('') || '', - default: null, // TODO - check: null, // TODO - primary: - colDef.constraints - ?.filter(isConstraintNode) - .some((c) => c.Constraint.contype === 'CONSTR_PRIMARY') || - false, - unique: - colDef.constraints - ?.filter(isConstraintNode) - .some((c) => c.Constraint.contype === 'CONSTR_UNIQUE') || false, - notNull: - colDef.constraints - ?.filter(isConstraintNode) - .some((c) => c.Constraint.contype === 'CONSTR_NOTNULL') || - // If primary key, it's not null - colDef.constraints - ?.filter(isConstraintNode) - .some((c) => c.Constraint.contype === 'CONSTR_PRIMARY') || - false, - increment: - colDef.typeName?.names - ?.filter(isStringNode) - .some((n) => n.String.sval === 'serial') || false, - comment: null, // TODO - } + function handleCreateStmt(createStmt: CreateStmt) { + if (!createStmt || !createStmt.relation || !createStmt.tableElts) return + + const tableName = createStmt.relation.relname + const columns: Columns = {} + for (const elt of createStmt.tableElts) { + if ('ColumnDef' in elt) { + const colDef = elt.ColumnDef + columns[colDef.colname || ''] = { + name: colDef.colname || '', + type: + colDef.typeName?.names + ?.filter(isStringNode) + .map((n) => n.String.sval) + .join('') || '', + default: null, // TODO + check: null, // TODO + primary: + colDef.constraints + ?.filter(isConstraintNode) + .some((c) => c.Constraint.contype === 'CONSTR_PRIMARY') || false, + unique: + colDef.constraints + ?.filter(isConstraintNode) + .some((c) => c.Constraint.contype === 'CONSTR_UNIQUE') || false, + notNull: + colDef.constraints + ?.filter(isConstraintNode) + .some((c) => c.Constraint.contype === 'CONSTR_NOTNULL') || + // If primary key, it's not null + colDef.constraints + ?.filter(isConstraintNode) + .some((c) => c.Constraint.contype === 'CONSTR_PRIMARY') || + false, + increment: + colDef.typeName?.names + ?.filter(isStringNode) + .some((n) => n.String.sval === 'serial') || false, + comment: null, // TODO + } - // Handle REFERENCES constraints for relationships - - // Update or delete constraint for foreign key - // see: https://github.com/launchql/pgsql-parser/blob/pgsql-parser%4013.16.0/packages/deparser/src/deparser.ts#L3101-L3141 - const getConstraintAction = (action?: string): string => { - switch (action?.toLowerCase()) { - case 'r': - return 'RESTRICT' - case 'c': - return 'CASCADE' - case 'n': - return 'SET NULL' - case 'd': - return 'SET DEFAULT' - case 'a': - return 'NO ACTION' - default: - return 'NO ACTION' // Default to 'NO ACTION' for unknown or missing values - } + // Handle REFERENCES constraints for relationships + + // Update or delete constraint for foreign key + // see: https://github.com/launchql/pgsql-parser/blob/pgsql-parser%4013.16.0/packages/deparser/src/deparser.ts#L3101-L3141 + const getConstraintAction = (action?: string): string => { + switch (action?.toLowerCase()) { + case 'r': + return 'RESTRICT' + case 'c': + return 'CASCADE' + case 'n': + return 'SET NULL' + case 'd': + return 'SET DEFAULT' + case 'a': + return 'NO ACTION' + default: + return 'NO ACTION' // Default to 'NO ACTION' for unknown or missing values } + } - for (const constraint of (colDef.constraints ?? []).filter( - isConstraintNode, - )) { - if (constraint.Constraint.contype !== 'CONSTR_FOREIGN') { - continue - } + for (const constraint of (colDef.constraints ?? []).filter( + isConstraintNode, + )) { + if (constraint.Constraint.contype !== 'CONSTR_FOREIGN') { + continue + } - const foreign = constraint.Constraint - const primaryTableName = foreign.pktable?.relname - const primaryColumnName = - foreign.pk_attrs?.[0] && isStringNode(foreign.pk_attrs[0]) - ? foreign.pk_attrs[0].String.sval - : undefined + const foreign = constraint.Constraint + const primaryTableName = foreign.pktable?.relname + const primaryColumnName = + foreign.pk_attrs?.[0] && isStringNode(foreign.pk_attrs[0]) + ? foreign.pk_attrs[0].String.sval + : undefined - if (!primaryTableName || !primaryColumnName) { - throw new Error('Invalid foreign key constraint') - } + if (!primaryTableName || !primaryColumnName) { + throw new Error('Invalid foreign key constraint') + } - const foreignColumnName = colDef.colname || '' - - if (primaryTableName && tableName) { - // relationshipName example: "users_id_to_posts_user_id" - const relationshipName = `${primaryTableName}_${primaryColumnName}_to_${tableName}_${foreignColumnName}` - const updateConstraint = getConstraintAction( - foreign.fk_upd_action, - ) - const deleteConstraint = getConstraintAction( - foreign.fk_del_action, - ) - - relationships[relationshipName] = { - name: relationshipName, - primaryTableName, - primaryColumnName, - foreignTableName: tableName, - foreignColumnName, - cardinality: 'ONE_TO_MANY', // TODO: Consider implementing other cardinalities - updateConstraint, - deleteConstraint, - } + const foreignColumnName = colDef.colname || '' + + if (primaryTableName && tableName) { + // relationshipName example: "users_id_to_posts_user_id" + const relationshipName = `${primaryTableName}_${primaryColumnName}_to_${tableName}_${foreignColumnName}` + const updateConstraint = getConstraintAction(foreign.fk_upd_action) + const deleteConstraint = getConstraintAction(foreign.fk_del_action) + + relationships[relationshipName] = { + name: relationshipName, + primaryTableName, + primaryColumnName, + foreignTableName: tableName, + foreignColumnName, + cardinality: 'ONE_TO_MANY', // TODO: Consider implementing other cardinalities + updateConstraint, + deleteConstraint, } } } } + } - if (tableName) { - tables[tableName] = { - name: tableName, - columns, - comment: null, // TODO - indices: [], // TODO + if (tableName) { + tables[tableName] = { + name: tableName, + columns, + comment: null, // TODO + indices: {}, + } + } + } + + function handleIndexStmt(indexStmt: IndexStmt) { + if ( + !indexStmt || + !indexStmt.idxname || + !indexStmt.relation || + !indexStmt.indexParams + ) + return + + const indexName = indexStmt.idxname + const tableName = indexStmt.relation.relname + const unique = indexStmt.unique !== undefined + const columns = indexStmt.indexParams + .map((param) => { + if ('IndexElem' in param) { + return param.IndexElem.name } + return undefined + }) + .filter((name): name is string => name !== undefined) + + if (tableName) { + tables[tableName] = { + name: tables[tableName]?.name || tableName, + comment: tables[tableName]?.comment || null, + columns: tables[tableName]?.columns || {}, + indices: { + ...tables[tableName]?.indices, + [indexName]: { + name: indexName, + unique: unique, + columns, + }, + }, } } } + if (!ast) { + return { + tables: {}, + relationships: {}, + } + } + + // pg-query-emscripten does not have types, so we need to define them ourselves + // @ts-expect-error + for (const statement of ast.parse_tree.stmts) { + if (statement?.stmt === undefined) continue + + const stmt = statement.stmt + if (isCreateStmt(stmt)) { + handleCreateStmt(stmt.CreateStmt) + } else if (isIndexStmt(stmt)) { + handleIndexStmt(stmt.IndexStmt) + } + } + return { tables, relationships, diff --git a/frontend/packages/db-structure/src/parser/sql/postgresql/index.test.ts b/frontend/packages/db-structure/src/parser/sql/postgresql/index.test.ts index d69b99d67..a04944c35 100644 --- a/frontend/packages/db-structure/src/parser/sql/postgresql/index.test.ts +++ b/frontend/packages/db-structure/src/parser/sql/postgresql/index.test.ts @@ -18,8 +18,16 @@ describe(processor, () => { primary: true, increment: true, }), + name: aColumn({ + name: 'name', + type: 'varchar', + notNull: false, + }), ...override?.columns, }, + indices: { + ...override?.indices, + }, }), }, }) @@ -53,15 +61,7 @@ describe(processor, () => { ); `) - const expected = userTable({ - columns: { - name: aColumn({ - name: 'name', - type: 'varchar', - notNull: false, - }), - }, - }) + const expected = userTable() expect(result).toEqual(expected) }) @@ -95,6 +95,52 @@ describe(processor, () => { expect(result.relationships).toEqual(expectedRelationships) }) + it('index', async () => { + const result = await processor(/* PostgreSQL */ ` + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ); + + CREATE INDEX index_users_on_id_and_name ON public.users USING btree (id, name); + `) + + const expected = userTable({ + indices: { + index_users_on_id_and_name: { + name: 'index_users_on_id_and_name', + unique: false, + columns: ['id', 'name'], + }, + }, + }) + + expect(result).toEqual(expected) + }) + + it('unique index', async () => { + const result = await processor(/* PostgreSQL */ ` + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ); + + CREATE UNIQUE INDEX index_users_on_id_and_name ON public.users USING btree (id, name); + `) + + const expected = userTable({ + indices: { + index_users_on_id_and_name: { + name: 'index_users_on_id_and_name', + unique: true, + columns: ['id', 'name'], + }, + }, + }) + + expect(result).toEqual(expected) + }) + // TODO: Implement default value }) }) diff --git a/frontend/packages/db-structure/src/schema/dbStructure.ts b/frontend/packages/db-structure/src/schema/dbStructure.ts index d36e12b1f..fec0c070b 100644 --- a/frontend/packages/db-structure/src/schema/dbStructure.ts +++ b/frontend/packages/db-structure/src/schema/dbStructure.ts @@ -4,6 +4,8 @@ const columnNameSchema = v.string() const tableNameSchema = v.string() +const indexNameSchema = v.string() + const relationshipNameSchema = v.string() const columnSchema = v.object({ @@ -27,12 +29,16 @@ const indexSchema = v.object({ unique: v.boolean(), columns: v.array(v.string()), }) +export type Index = v.InferOutput + +const indicesSchema = v.record(indexNameSchema, indexSchema) +export type Indices = v.InferOutput const tableSchema = v.object({ name: tableNameSchema, columns: columnsSchema, comment: v.nullable(v.string()), - indices: v.array(indexSchema), + indices: indicesSchema, }) export type Table = v.InferOutput diff --git a/frontend/packages/db-structure/src/schema/factories.ts b/frontend/packages/db-structure/src/schema/factories.ts index 5514b133e..4c735f450 100644 --- a/frontend/packages/db-structure/src/schema/factories.ts +++ b/frontend/packages/db-structure/src/schema/factories.ts @@ -1,4 +1,10 @@ -import type { Column, DBStructure, Table, Tables } from './dbStructure.js' +import type { + Column, + DBStructure, + Index, + Table, + Tables, +} from './dbStructure.js' export const aColumn = (override?: Partial): Column => ({ name: 'id', @@ -17,12 +23,21 @@ export const aTable = (override?: Partial): Table => ({ name: 'users', comment: null, ...override, - indices: [], + indices: { + ...override?.indices, + }, columns: { ...override?.columns, }, }) +export const anIndex = (override?: Partial): Index => ({ + name: '', + unique: false, + columns: [], + ...override, +}) + const tables = (override?: Tables): Tables => { return ( override ?? { diff --git a/frontend/packages/db-structure/src/schema/index.ts b/frontend/packages/db-structure/src/schema/index.ts index 9fd7dbde8..1281cd150 100644 --- a/frontend/packages/db-structure/src/schema/index.ts +++ b/frontend/packages/db-structure/src/schema/index.ts @@ -6,5 +6,7 @@ export type { Table, Tables, Relationship, + Index, + Indices, } from './dbStructure.js' -export { aColumn, aTable, aDBStructure } from './factories.js' +export { aColumn, aTable, aDBStructure, anIndex } from './factories.js'