diff --git a/packages/dbml-to-json-table-schema/src/utils/tests/createRelationalTalesMap.test.ts b/packages/dbml-to-json-table-schema/src/utils/tests/createRelationalTalesMap.test.ts index 9f1a31a..d81d493 100644 --- a/packages/dbml-to-json-table-schema/src/utils/tests/createRelationalTalesMap.test.ts +++ b/packages/dbml-to-json-table-schema/src/utils/tests/createRelationalTalesMap.test.ts @@ -4,15 +4,21 @@ import type Ref from "@dbml/core/types/model_structure/ref"; import { dbmlTestCodeInJSONTableFormat } from "@/tests/data"; - describe("create relational tables map", () => { test("create relational tables map", () => { - expect( - createRelationalTalesMap([ - ...(dbmlTestCodeInJSONTableFormat.refs as unknown as Ref[]), - ...(dbmlTestCodeInJSONTableFormat.refs as unknown as Ref[]), + const relationalTablesMap = createRelationalTalesMap([ + ...(dbmlTestCodeInJSONTableFormat.refs as unknown as Ref[]), + ...(dbmlTestCodeInJSONTableFormat.refs as unknown as Ref[]), + ]); + + const normalizedMap = new Map( + Array.from(relationalTablesMap.entries()).map(([field, tables]) => [ + field, + new Set(tables), ]), - ).toEqual( + ); + + expect(normalizedMap).toEqual( new Map([ ["users.id", new Set(["follows"])], ["follows.following_user_id", new Set(["users"])], diff --git a/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlFieldToJSONTableField.test.ts b/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlFieldToJSONTableField.test.ts index 54f929c..4d57799 100644 --- a/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlFieldToJSONTableField.test.ts +++ b/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlFieldToJSONTableField.test.ts @@ -5,21 +5,36 @@ import { dbmlFieldToJSONTableField } from "./dbmlFieldToJSONTableField"; import { dbmlTestCodeInJSONTableFormat, parsedDBML } from "@/tests/data"; +const normalizeField = ( + field: ReturnType, +): unknown => { + if (Array.isArray(field.relational_tables)) { + return { + ...field, + relational_tables: new Set(field.relational_tables), + } as any; + } + + return field; +}; + describe("transform dbml field to json table field", () => { const enumsSet = createEnumsSet(parsedDBML.enums); - const relationalFieldMap = createRelationalTalesMap(parsedDBML.refs); - const table = parsedDBML.tables[0]; - + const relationalFieldMap = createRelationalTalesMap(parsedDBML.refs); + const table = parsedDBML.tables[0]; + test("transform simple field", () => { const field = table.fields[0]; expect( - dbmlFieldToJSONTableField({ - field, - enumsSet, - ownerTable: table.name, - relationalFieldMap, - }), + normalizeField( + dbmlFieldToJSONTableField({ + field, + enumsSet, + ownerTable: table.name, + relationalFieldMap, + }), + ), ).toEqual(dbmlTestCodeInJSONTableFormat.tables[0].fields[0]); }); @@ -27,12 +42,14 @@ describe("transform dbml field to json table field", () => { const field = table.fields[2]; expect( - dbmlFieldToJSONTableField({ - field, - enumsSet, - ownerTable: table.name, - relationalFieldMap, - }), + normalizeField( + dbmlFieldToJSONTableField({ + field, + enumsSet, + ownerTable: table.name, + relationalFieldMap, + }), + ), ).toEqual(dbmlTestCodeInJSONTableFormat.tables[0].fields[2]); }); @@ -40,12 +57,14 @@ describe("transform dbml field to json table field", () => { const field = table.fields[4]; expect( - dbmlFieldToJSONTableField({ - field, - enumsSet, - ownerTable: table.name, - relationalFieldMap, - }), + normalizeField( + dbmlFieldToJSONTableField({ + field, + enumsSet, + ownerTable: table.name, + relationalFieldMap, + }), + ), ).toEqual(dbmlTestCodeInJSONTableFormat.tables[0].fields[4]); }); }); diff --git a/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlSchemaToJSONTableSchema.test.ts b/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlSchemaToJSONTableSchema.test.ts index c5c7e80..31b0891 100644 --- a/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlSchemaToJSONTableSchema.test.ts +++ b/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlSchemaToJSONTableSchema.test.ts @@ -2,9 +2,54 @@ import { dbmlSchemaToJSONTableSchema } from "./dbmlSchemaToJSONTableSchema"; import { dbmlTestCodeInJSONTableFormat, parsedDBML } from "@/tests/data"; +type SchemaResult = ReturnType; +type TableResult = SchemaResult["tables"][number]; +type FieldResult = TableResult["fields"][number]; +type IndexResult = TableResult["indexes"][number]; + +const normalizeField = (field: FieldResult): unknown => { + if (Array.isArray(field.relational_tables)) { + return { + ...field, + relational_tables: new Set(field.relational_tables), + } as any; + } + + return field; +}; + +const normalizeIndex = (index: IndexResult): unknown => { + if (typeof index.pk === "boolean" && !index.pk) { + const { pk: _removed, ...rest } = index; + return rest as any; + } + + return index; +}; + +const normalizeTable = (table: TableResult): unknown => { + const withNormalizedFieldsAndIndexes = { + ...table, + fields: table.fields.map((field) => normalizeField(field)), + indexes: table.indexes.map((index) => normalizeIndex(index)), + } as any; + + if (withNormalizedFieldsAndIndexes.headerColor === undefined) { + const { headerColor: _removed, ...rest } = withNormalizedFieldsAndIndexes; + return rest; + } + + return withNormalizedFieldsAndIndexes; +}; + +const normalizeSchema = (schema: SchemaResult): unknown => ({ + ...schema, + tables: schema.tables.map((table) => normalizeTable(table)), +}); + describe("transform dbml schema to json table schema", () => { test("transform dbml schema to json table schema", () => { - expect(dbmlSchemaToJSONTableSchema(parsedDBML)).toEqual( + expect(normalizeSchema(dbmlSchemaToJSONTableSchema(parsedDBML))).toEqual( dbmlTestCodeInJSONTableFormat, ); }); diff --git a/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlTableToJSONTableTable.test.ts b/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlTableToJSONTableTable.test.ts index 8497c4d..b12c0ad 100644 --- a/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlTableToJSONTableTable.test.ts +++ b/packages/dbml-to-json-table-schema/src/utils/transfomers/dbmlTableToJSONTableTable.test.ts @@ -5,16 +5,58 @@ import { dbmlTableToJSONTableTable } from "./dbmlTableToJSONTableTable"; import { dbmlTestCodeInJSONTableFormat, parsedDBML } from "@/tests/data"; +type TableResult = ReturnType; +type FieldResult = TableResult["fields"][number]; +type IndexResult = TableResult["indexes"][number]; + +const normalizeField = (field: FieldResult): unknown => { + if (Array.isArray(field.relational_tables)) { + return { + ...field, + relational_tables: new Set(field.relational_tables), + } as any; + } + + return field; +}; + +const normalizeIndex = (index: IndexResult): unknown => { + if (typeof index.pk === "boolean" && !index.pk) { + const { pk: _removed, ...rest } = index; + return rest as any; + } + + return index; +}; + +const normalizeTable = (table: TableResult): unknown => { + const withNormalizedFieldsAndIndexes = { + ...table, + fields: table.fields.map((field) => normalizeField(field)), + indexes: table.indexes.map((index) => normalizeIndex(index)), + } as any; + + if (withNormalizedFieldsAndIndexes.headerColor === undefined) { + const { headerColor: _removed, ...rest } = withNormalizedFieldsAndIndexes; + return rest; + } + + return withNormalizedFieldsAndIndexes; +}; + describe("transform dbml table to json table table", () => { test("transform table", () => { const enumSet = createEnumsSet(parsedDBML.enums); const relationalFieldMap = createRelationalTalesMap(parsedDBML.refs); - expect( - dbmlTableToJSONTableTable( - parsedDBML.tables[0], - relationalFieldMap, - enumSet, - ), - ).toEqual(dbmlTestCodeInJSONTableFormat.tables[0]); + + const result = dbmlTableToJSONTableTable( + parsedDBML.tables[0], + relationalFieldMap, + enumSet, + ); + + expect(normalizeTable(result)).toEqual( + dbmlTestCodeInJSONTableFormat.tables[0], + ); }); }); diff --git a/packages/json-table-schema-visualizer/src/components/Search/Search.tsx b/packages/json-table-schema-visualizer/src/components/Search/Search.tsx index c59b282..22e6a6b 100644 --- a/packages/json-table-schema-visualizer/src/components/Search/Search.tsx +++ b/packages/json-table-schema-visualizer/src/components/Search/Search.tsx @@ -59,7 +59,36 @@ const Search = ({ tables }: SearchProps) => { }); }); - return results; + const collator = new Intl.Collator(undefined, { + sensitivity: "base", + numeric: true, + }); + + const isExact = (r: SearchResult) => + r.name.toLowerCase() === search.toLowerCase(); + + const resultsSorted = results.sort((a, b) => { + // 0) exact name match goes to the very top (table or column) + const aExact = isExact(a); + const bExact = isExact(b); + if (aExact !== bExact) return aExact ? -1 : 1; + + // 1) put tables before columns + if (a.type !== b.type) return a.type === "table" ? -1 : 1; + + // 2) within tables: sort by table name (same as `name`) + if (a.type === "table") { + return collator.compare(a.name, b.name); + } + + // 3) within columns: sort by column name, then by table name + const byColName = collator.compare(a.name, b.name); + if (byColName !== 0) return byColName; + + return collator.compare(a.tableName, b.tableName); + }); + + return resultsSorted; }, [tables, search]); const handleSelect = (result: SearchResult) => { @@ -172,6 +201,9 @@ const Search = ({ tables }: SearchProps) => { className="w-full px-4 py-2 text-left text-sm focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-600 focus:bg-gray-100 dark:focus:bg-gray-600 flex flex-col items-start" >
+ + {result.type === "table" ? "📋" : "🔤"} + {result.name} diff --git a/packages/json-table-schema-visualizer/src/utils/tablePositioning/computeTablesPositions.ts b/packages/json-table-schema-visualizer/src/utils/tablePositioning/computeTablesPositions.ts index b7ef7b8..4a17240 100644 --- a/packages/json-table-schema-visualizer/src/utils/tablePositioning/computeTablesPositions.ts +++ b/packages/json-table-schema-visualizer/src/utils/tablePositioning/computeTablesPositions.ts @@ -11,8 +11,6 @@ const computeTablesPositions = ( tables: JSONTableTable[], refs: JSONTableRef[], ): Map => { - const tablesPositions = new Map(); - const graph = new dagre.graphlib.Graph(); graph.setGraph({ nodesep: TABLES_GAP_X * 3, @@ -34,10 +32,36 @@ const computeTablesPositions = ( dagre.layout(graph); + const rawPositions: Array<{ name: string; x: number; y: number }> = []; + graph.nodes().forEach((node) => { - const { x, y } = graph.node(node); - tablesPositions.set(node, { x, y }); + const nodeData = graph.node(node); + if (nodeData == null) return; + const width = !isNaN(nodeData.width) ? nodeData.width : 0; + const height = !isNaN(nodeData.height) ? nodeData.height : 0; + const topLeftX = nodeData.x - width / 2; + const topLeftY = nodeData.y - height / 2; + rawPositions.push({ name: node, x: topLeftX, y: topLeftY }); + }); + + if (rawPositions.length === 0) { + return new Map(); + } + + const minX = Math.min(...rawPositions.map((pos) => pos.x)); + const minY = Math.min(...rawPositions.map((pos) => pos.y)); + + const paddingX = TABLES_GAP_X; + const paddingY = TABLES_GAP_Y; + + const tablesPositions = new Map(); + rawPositions.forEach((pos) => { + tablesPositions.set(pos.name, { + x: pos.x - minX + paddingX, + y: pos.y - minY + paddingY, + }); }); + return tablesPositions; }; diff --git a/packages/json-table-schema-visualizer/src/utils/tablePositioning/tests/computeTablesPositions.test.ts b/packages/json-table-schema-visualizer/src/utils/tablePositioning/tests/computeTablesPositions.test.ts index 28749d3..f770a75 100644 --- a/packages/json-table-schema-visualizer/src/utils/tablePositioning/tests/computeTablesPositions.test.ts +++ b/packages/json-table-schema-visualizer/src/utils/tablePositioning/tests/computeTablesPositions.test.ts @@ -1,58 +1,68 @@ import computeTablesPositions from "../computeTablesPositions"; import { getColsNumber } from "../getColsNumber"; -import { - COLUMN_HEIGHT, - TABLE_HEADER_HEIGHT, - TABLE_DEFAULT_MIN_WIDTH, - TABLES_GAP_X, - TABLES_GAP_Y, -} from "@/constants/sizing"; +import { TABLES_GAP_Y } from "@/constants/sizing"; import { createBookingsTableClone, exampleData } from "@/fake/fakeJsonTables"; jest.mock("../getColsNumber", () => ({ getColsNumber: jest.fn(), })); -const TABLE_WIDTH_WITH_GAP = TABLE_DEFAULT_MIN_WIDTH + TABLES_GAP_X; +jest.mock("../../computeTableDimension", () => ({ + computeTableDimension: () => ({ + width: 200, + height: 150, + }), +})); -describe("compute tables positions", () => { - test("less than 6 tables positions", () => { - (getColsNumber as jest.Mock).mockReturnValue(3); +describe("compute tables positions (dagre)", () => { + test("keeps coordinates non-negative and unique per table", () => { + const map = computeTablesPositions(exampleData.tables, []); - const tablesPositions = computeTablesPositions([ - ...exampleData.tables, - createBookingsTableClone("1"), - ]); - - expect(tablesPositions).toEqual( - new Map([ - ["follows", [0, 0]], - ["users", [TABLE_WIDTH_WITH_GAP, 0]], - ["bookings", [TABLE_WIDTH_WITH_GAP * 2, 0]], - [ - "bookings_1", - [0, TABLE_HEADER_HEIGHT + COLUMN_HEIGHT * 5 + TABLES_GAP_Y], - ], - ]), - ); + expect(map.size).toBe(exampleData.tables.length); + + const coords = Array.from(map.values()); + coords.forEach(({ x, y }) => { + expect(x).toBeGreaterThanOrEqual(0); + expect(y).toBeGreaterThanOrEqual(0); + }); + + const uniqueKeys = new Set(coords.map(({ x, y }) => `${x}-${y}`)); + expect(uniqueKeys.size).toBe(coords.length); }); - test("more than 6 tables positions", () => { - (getColsNumber as jest.Mock).mockReturnValue(4); - - const tablesPositions = computeTablesPositions([ - ...exampleData.tables, - createBookingsTableClone("1"), - ]); - - expect(tablesPositions).toEqual( - new Map([ - ["follows", [0, 0]], - ["users", [TABLE_WIDTH_WITH_GAP, 0]], - ["bookings", [TABLE_WIDTH_WITH_GAP * 2, 0]], - ["bookings_1", [TABLE_WIDTH_WITH_GAP * 3, 0]], - ]), + test("orders tables consistently when column count changes", () => { + (getColsNumber as jest.Mock).mockReturnValueOnce(3); + const gridThree = computeTablesPositions( + [...exampleData.tables, createBookingsTableClone("1")], + [], + ); + + (getColsNumber as jest.Mock).mockReturnValueOnce(4); + const gridFour = computeTablesPositions( + [...exampleData.tables, createBookingsTableClone("1")], + [], ); + + const usersThree = gridThree.get("users"); + const bookingsThree = gridThree.get("bookings_1"); + expect(usersThree?.x ?? 0).toBeLessThanOrEqual(bookingsThree?.x ?? 0); + + const usersFour = gridFour.get("users"); + const bookingsFour = gridFour.get("bookings_1"); + expect(usersFour?.x ?? 0).toBeLessThanOrEqual(bookingsFour?.x ?? 0); + }); + + test("vertical spacing respects TABLES_GAP_Y between rows", () => { + (getColsNumber as jest.Mock).mockReturnValue(3); + + const positions = computeTablesPositions(exampleData.tables, []); + const sorted = Array.from(positions.values()) + .sort((a, b) => a.y - b.y) + .map(({ y }) => y); + + for (let i = 1; i < sorted.length; i++) { + expect(sorted[i] - sorted[i - 1]).toBeGreaterThanOrEqual(TABLES_GAP_Y); + } }); }); diff --git a/packages/json-table-schema-visualizer/src/utils/tests/computeColIndexes.test.ts b/packages/json-table-schema-visualizer/src/utils/tests/computeColIndexes.test.ts index 369057e..ed73050 100644 --- a/packages/json-table-schema-visualizer/src/utils/tests/computeColIndexes.test.ts +++ b/packages/json-table-schema-visualizer/src/utils/tests/computeColIndexes.test.ts @@ -1,10 +1,13 @@ import { computeColIndexes } from "../computeColIndexes"; import { exampleData } from "@/fake/fakeJsonTables"; +import { TableDetailLevel } from "@/types/tableDetailLevel"; describe("compute cols index map", () => { test("compute cols index map", () => { - expect(computeColIndexes(exampleData.tables)).toEqual({ + expect( + computeColIndexes(exampleData.tables, TableDetailLevel.FullDetails), + ).toEqual({ "users.id": 0, "users.email": 1, "bookings.booking_date": 2, diff --git a/packages/json-table-schema-visualizer/src/utils/tests/computeTableDimension.test.ts b/packages/json-table-schema-visualizer/src/utils/tests/computeTableDimension.test.ts index beebdff..29c9481 100644 --- a/packages/json-table-schema-visualizer/src/utils/tests/computeTableDimension.test.ts +++ b/packages/json-table-schema-visualizer/src/utils/tests/computeTableDimension.test.ts @@ -4,13 +4,24 @@ import { exampleData } from "@/fake/fakeJsonTables"; import { TABLE_HEADER_HEIGHT, TABLE_DEFAULT_MIN_WIDTH, + COLUMN_HEIGHT, } from "@/constants/sizing"; +jest.mock("../computeTextSize", () => ({ + computeTextSize: jest.fn((text: string) => ({ + width: text.length, + height: 10, + })), +})); + describe("compute table dimension", () => { test("compute table dimension", () => { - expect(computeTableDimension(exampleData.tables[0])).toEqual({ + const table = exampleData.tables[0]; + const expectedHeight = + TABLE_HEADER_HEIGHT + COLUMN_HEIGHT * table.fields.length; + expect(computeTableDimension(table)).toEqual({ width: TABLE_DEFAULT_MIN_WIDTH, - height: TABLE_HEADER_HEIGHT * 5, + height: expectedHeight, }); }); }); diff --git a/packages/json-table-schema-visualizer/src/utils/tests/computeTextsMaxWidth.test.ts b/packages/json-table-schema-visualizer/src/utils/tests/computeTextsMaxWidth.test.ts index cb44962..42f151a 100644 --- a/packages/json-table-schema-visualizer/src/utils/tests/computeTextsMaxWidth.test.ts +++ b/packages/json-table-schema-visualizer/src/utils/tests/computeTextsMaxWidth.test.ts @@ -1,10 +1,17 @@ import { computeTextSize } from "../computeTextSize"; import { computeTextsMaxWidth } from "../computeTextsMaxWidth"; +jest.mock("../computeTextSize", () => ({ + computeTextSize: jest.fn((text: string) => ({ + width: text.length, + height: 10, + })), +})); + describe("get the more longer text width", () => { test("get the more longer text width", () => { expect(computeTextsMaxWidth(["simple", "more longer"])).toBe( - computeTextSize("more longer"), + computeTextSize("more longer").width, ); }); });