diff --git a/frontend/packages/cli/README.md b/frontend/packages/cli/README.md index e85f21352..066960421 100644 --- a/frontend/packages/cli/README.md +++ b/frontend/packages/cli/README.md @@ -3,9 +3,17 @@ Command-line tool designed to generate a web application that displays ER diagrams. ```bash -$ liam erd build --input {your .sql} +$ liam erd build --input {your .sql} --format postgres # Outputs the web application to the ./public and ./dist directories +``` +```bash +# Or use a `db/schema.rb` file (from your Ruby on Rails app). +$ liam erd build --input {your schema.rb} --format schemarb +# Outputs the web application to the `./dist` directory +``` + +```bash $ liam erd preview # Launches the web application for preview ``` @@ -28,7 +36,7 @@ pnpm run build After building, you can invoke it locally with: ```bash -node ./dist-cli/bin/cli.js erd build --input ./fixtures/input.schema.rb +node ./dist-cli/bin/cli.js erd build --input ./fixtures/input.schema.rb --format schemarb ``` To make it globally accessible as `liam`, use: @@ -47,7 +55,7 @@ pnpm link --global ``` This command currently performs the following actions: - Builds the CLI. - - Executes the CLI with the command `erd build --input ./fixtures/input.schema.rb`. + - Executes the CLI with the command `erd build --input ./fixtures/input.schema.rb --format schemarb`. - Copies the generated `schema.json` to the `public/` directory and launches the Vite development server. ## Project File Structure diff --git a/frontend/packages/cli/fixtures/input.sql b/frontend/packages/cli/fixtures/input.sql index 691c79079..9d96fae68 100644 --- a/frontend/packages/cli/fixtures/input.sql +++ b/frontend/packages/cli/fixtures/input.sql @@ -1,5 +1,11 @@ -CREATE TABLE products ( - brand VARCHAR(255), - model VARCHAR(255), - year INT +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ); diff --git a/frontend/packages/cli/package.json b/frontend/packages/cli/package.json index d3c5d4121..1423dbaf6 100644 --- a/frontend/packages/cli/package.json +++ b/frontend/packages/cli/package.json @@ -38,7 +38,7 @@ "build": "pnpm run '/^build:.*/'", "build:cli": "rollup -c && pnpm run cp:prism", "build:vite": "vite build", - "command:build": "node ./dist-cli/bin/cli.js erd build --input fixtures/input.schema.rb", + "command:build": "node ./dist-cli/bin/cli.js erd build --input fixtures/input.schema.rb --format schemarb", "cp:prism": "cp ../db-structure/node_modules/@ruby/prism/src/prism.wasm ./dist-cli/bin/prism.wasm", "dev": "pnpm command:build && cp dist/schema.json public/ && pnpm run '/^dev:.*/'", "dev:app": "vite", diff --git a/frontend/packages/cli/src/cli/commands/buildCommand.ts b/frontend/packages/cli/src/cli/commands/buildCommand.ts index 10ca15f7b..a43219144 100644 --- a/frontend/packages/cli/src/cli/commands/buildCommand.ts +++ b/frontend/packages/cli/src/cli/commands/buildCommand.ts @@ -3,11 +3,16 @@ import { existsSync } from 'node:fs' import { dirname } from 'node:path' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import type { SupportedFormat } from '@liam-hq/db-structure/parser' import { runPreprocess } from '../runPreprocess.js' -export const buildCommand = async (inputPath: string, outDir: string) => { +export const buildCommand = async ( + inputPath: string, + outDir: string, + format: SupportedFormat, +) => { // generate schema.json - runPreprocess(inputPath, outDir) + runPreprocess(inputPath, outDir, format) // generate index.html const __filename = fileURLToPath(import.meta.url) diff --git a/frontend/packages/cli/src/cli/index.test.ts b/frontend/packages/cli/src/cli/index.test.ts index 323dd8f59..d624d4c66 100644 --- a/frontend/packages/cli/src/cli/index.test.ts +++ b/frontend/packages/cli/src/cli/index.test.ts @@ -35,14 +35,24 @@ describe('program', () => { }) describe('commands', () => { - it('should call buildCommand when "build" command is executed', () => { - program.parse(['erd', 'build', '--input', 'path/to/file.sql'], { - from: 'user', - }) - expect(buildCommand).toHaveBeenCalledWith( - 'path/to/file.sql', - expect.stringMatching(/\/dist$/), - ) - }) + it.each([['schemarb'], ['postgres']])( + 'should call buildCommand with correct arguments for format %s', + (format) => { + const inputFile = './fixtures/input.schema.rb' + + program.parse( + ['erd', 'build', '--input', inputFile, '--format', format], + { + from: 'user', + }, + ) + + expect(buildCommand).toHaveBeenCalledWith( + inputFile, + expect.stringContaining('dist'), + format, + ) + }, + ) }) }) diff --git a/frontend/packages/cli/src/cli/index.ts b/frontend/packages/cli/src/cli/index.ts index 3c13bd71c..239892c69 100644 --- a/frontend/packages/cli/src/cli/index.ts +++ b/frontend/packages/cli/src/cli/index.ts @@ -15,6 +15,7 @@ erdCommand .command('build') .description('Build ERD html assets') .option('--input ', 'Path to the .sql file') - .action((options) => buildCommand(options.input, distDir)) + .option('--format ', 'Format of the input file') + .action((options) => buildCommand(options.input, distDir, options.format)) export { program } diff --git a/frontend/packages/cli/src/cli/runPreprocess.test.ts b/frontend/packages/cli/src/cli/runPreprocess.test.ts index 82f7b5be8..f7f2387b8 100644 --- a/frontend/packages/cli/src/cli/runPreprocess.test.ts +++ b/frontend/packages/cli/src/cli/runPreprocess.test.ts @@ -1,32 +1,74 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import type { SupportedFormat } from '@liam-hq/db-structure/parser' import { describe, expect, it } from 'vitest' import { runPreprocess } from './runPreprocess.js' describe('runPreprocess', () => { - it('should create schema.json with the SQL content in the specified directory', async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-distDir-')) - const inputPath = path.join(tmpDir, 'input.sql') + const testCases = [ + { + format: 'postgres', + inputFilename: 'input.sql', + content: 'CREATE TABLE test (id INT, name VARCHAR(255));', + }, + { + format: 'schemarb', + inputFilename: 'input.schema.rb', + content: ` + create_table "test" do |t| + t.integer "id" + t.string "name", limit: 255 + end + `, + }, + ] - const sqlContent = 'CREATE TABLE test (id INT, name VARCHAR(255));' - fs.writeFileSync(inputPath, sqlContent, 'utf8') + it.each(testCases)( + 'should create schema.json with the content in $format format', + async ({ format, inputFilename, content }) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-distDir-')) + const inputPath = path.join(tmpDir, inputFilename) - const outputFilePath = await runPreprocess(inputPath, tmpDir) - if (!outputFilePath) throw new Error('Implement the test') + fs.writeFileSync(inputPath, content, 'utf8') - expect(fs.existsSync(outputFilePath)).toBe(true) + const outputFilePath = await runPreprocess( + inputPath, + tmpDir, + format as SupportedFormat, + ) + if (!outputFilePath) throw new Error('Failed to run preprocess') - const outputContent = JSON.parse(fs.readFileSync(outputFilePath, 'utf8')) - expect(outputContent.tables).toBeDefined() - }) + expect(fs.existsSync(outputFilePath)).toBe(true) + + // Validate output file content + const outputContent = JSON.parse(fs.readFileSync(outputFilePath, 'utf8')) + expect(outputContent.tables).toBeDefined() + }, + ) it('should throw an error if the input file does not exist', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-distDir-')) const nonExistentPath = path.join(tmpDir, 'non-existent.sql') - await expect(runPreprocess(nonExistentPath, tmpDir)).rejects.toThrow( - 'Invalid input path. Please provide a valid file.', + await expect( + runPreprocess(nonExistentPath, tmpDir, 'postgres'), + ).rejects.toThrow('Invalid input path. Please provide a valid file.') + }) + + it('should throw an error if the format is invalid', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-distDir-')) + const inputPath = path.join(tmpDir, 'input.sql') + fs.writeFileSync( + inputPath, + 'CREATE TABLE test (id INT, name VARCHAR(255));', + 'utf8', + ) + + await expect( + runPreprocess(inputPath, tmpDir, 'invalid' as SupportedFormat), + ).rejects.toThrow( + '--format is missing, invalid, or specifies an unsupported format. Please provide a valid format (e.g., "schemarb" or "postgres").', ) }) }) diff --git a/frontend/packages/cli/src/cli/runPreprocess.ts b/frontend/packages/cli/src/cli/runPreprocess.ts index dadcd5bd7..11712cb8d 100644 --- a/frontend/packages/cli/src/cli/runPreprocess.ts +++ b/frontend/packages/cli/src/cli/runPreprocess.ts @@ -1,17 +1,37 @@ import fs, { readFileSync } from 'node:fs' import path from 'node:path' -import { parse } from '@liam-hq/db-structure/parser' +import { + type SupportedFormat, + parse, + supportedFormatSchema, +} from '@liam-hq/db-structure/parser' +import * as v from 'valibot' -export async function runPreprocess(inputPath: string, outputDir: string) { +export async function runPreprocess( + inputPath: string, + outputDir: string, + format: SupportedFormat, +) { if (!fs.existsSync(inputPath)) { throw new Error('Invalid input path. Please provide a valid file.') } const input = readFileSync(inputPath, 'utf8') - // TODO: Expand support to additional formats, e.g., 'postgres' - const format = 'schemarb' - const json = await parse(input, format) + if (!v.safeParse(supportedFormatSchema, format).success) { + throw new Error( + '--format is missing, invalid, or specifies an unsupported format. Please provide a valid format (e.g., "schemarb" or "postgres").', + ) + } + + let json = null + try { + json = await parse(input, format) + } catch (error) { + throw new Error( + `Failed to parse ${format} file: ${error instanceof Error ? error.message : String(error)}`, + ) + } const filePath = path.join(outputDir, 'schema.json') diff --git a/frontend/packages/cli/src/cli/smoke.test.ts b/frontend/packages/cli/src/cli/smoke.test.ts index 9aaa257dc..47419c146 100644 --- a/frontend/packages/cli/src/cli/smoke.test.ts +++ b/frontend/packages/cli/src/cli/smoke.test.ts @@ -45,7 +45,7 @@ describe('CLI Smoke Test', () => { await execAsync('rm -rf ./dist') try { const { stdout, stderr } = await execAsync( - 'npx --no-install . erd build --input fixtures/input.schema.rb', + 'npx --no-install . erd build --input fixtures/input.schema.rb --format schemarb', ) // NOTE: suppress the following warning: if ( diff --git a/frontend/packages/db-structure/src/parser.ts b/frontend/packages/db-structure/src/parser.ts index 966b1e0c9..5f4f6fdec 100644 --- a/frontend/packages/db-structure/src/parser.ts +++ b/frontend/packages/db-structure/src/parser.ts @@ -1 +1,5 @@ -export { parse } from './parser/index.js' +export { + parse, + type SupportedFormat, + supportedFormatSchema, +} from './parser/index.js' diff --git a/frontend/packages/db-structure/src/parser/index.ts b/frontend/packages/db-structure/src/parser/index.ts index 561c92e8c..eb7096efe 100644 --- a/frontend/packages/db-structure/src/parser/index.ts +++ b/frontend/packages/db-structure/src/parser/index.ts @@ -1,8 +1,14 @@ +import * as v from 'valibot' import type { DBStructure } from '../schema/index.js' import { processor as schemarbProcessor } from './schemarb/index.js' import { processor as postgresqlProcessor } from './sql/index.js' -type SupportedFormat = 'schemarb' | 'postgres' +export const supportedFormatSchema = v.union([ + v.literal('schemarb'), + v.literal('postgres'), +]) + +export type SupportedFormat = v.InferOutput // TODO: Add error handling and tests export const parse = (