-
Notifications
You must be signed in to change notification settings - Fork 49
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
d714b0f
commit e13604a
Showing
6 changed files
with
339 additions
and
67 deletions.
There are no files selected for viewing
92 changes: 92 additions & 0 deletions
92
frontend/packages/cli/src/cli/erdCommand/runPreprocess.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
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 { ArgumentError, WarningProcessingError } from '../errors.js' | ||
import { runPreprocess } from './runPreprocess.js' | ||
|
||
describe('runPreprocess', () => { | ||
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 | ||
`, | ||
}, | ||
] as const | ||
|
||
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) | ||
|
||
fs.writeFileSync(inputPath, content, 'utf8') | ||
|
||
const { outputFilePath, errors } = await runPreprocess( | ||
inputPath, | ||
tmpDir, | ||
format, | ||
) | ||
if (!outputFilePath) throw new Error('Failed to run preprocess') | ||
|
||
expect(errors).toEqual([]) | ||
expect(fs.existsSync(outputFilePath)).toBe(true) | ||
|
||
// Validate output file content | ||
const outputContent = JSON.parse(fs.readFileSync(outputFilePath, 'utf8')) | ||
expect(outputContent.tables).toBeDefined() | ||
}, | ||
) | ||
|
||
it('should return 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', | ||
) | ||
|
||
const { outputFilePath, errors } = await runPreprocess( | ||
inputPath, | ||
tmpDir, | ||
'invalid' as SupportedFormat, | ||
) | ||
expect(outputFilePath).toBeNull() | ||
expect(errors).toEqual([ | ||
new ArgumentError( | ||
`--format is missing, invalid, or specifies an unsupported format. Please provide a valid format. | ||
Invalid type: Expected ("schemarb" | "postgres" | "prisma" | "tbls") but received "invalid"`, | ||
), | ||
]) | ||
}) | ||
|
||
it('should return an error if failed parsing schema file', async () => { | ||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-distDir-')) | ||
const inputPath = path.join(tmpDir, 'input.sql') | ||
fs.writeFileSync(inputPath, 'invalid;', 'utf8') | ||
|
||
const { outputFilePath, errors } = await runPreprocess( | ||
inputPath, | ||
tmpDir, | ||
'postgres', | ||
) | ||
expect(outputFilePath).toBeNull() | ||
expect(errors).toEqual([ | ||
new WarningProcessingError( | ||
'Error during parsing schema file: syntax error at or near "invalid"', | ||
), | ||
]) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
import { exec } from 'node:child_process' | ||
import { promisify } from 'node:util' | ||
import { beforeAll, describe, expect, it } from 'vitest' | ||
import { blueBright } from 'yoctocolors' | ||
|
||
// NOTE: This CLI smoke test is a preliminary implementation, lacks refinement, and is relatively slow. | ||
// We should explore alternative approaches for testing. | ||
|
||
const execAsync = promisify(exec) | ||
|
||
beforeAll(async () => { | ||
await execAsync('rm -rf ./dist-cli/ ./node_modules/.tmp') | ||
await execAsync('pnpm run build') | ||
}, 60000 /* 60 seconds for setup */) | ||
|
||
describe('CLI Smoke Test', () => { | ||
it('should run the CLI command without errors: `erd`', async () => { | ||
try { | ||
const { stdout, stderr } = await execAsync('npx --no-install . help') | ||
// NOTE: suppress the following warning: | ||
if ( | ||
!stderr.includes( | ||
'ExperimentalWarning: WASI is an experimental feature and might change at any time', | ||
) | ||
) { | ||
expect(stderr).toBe('') | ||
} | ||
expect(stdout).toMatchInlineSnapshot(` | ||
"Usage: liam [options] [command] | ||
CLI tool for Liam | ||
Options: | ||
-V, --version output the version number | ||
-h, --help display help for command | ||
Commands: | ||
erd ERD commands | ||
init guide you interactively through the setup | ||
help [command] display help for command | ||
" | ||
`) | ||
} catch (error) { | ||
// Fail the test if an error occurs | ||
expect(error).toBeNull() | ||
} | ||
}, 20000 /* 20 seconds for smoke test */) | ||
|
||
it('should run the CLI command without errors: `erd build`', async () => { | ||
await execAsync('rm -rf ./dist') | ||
try { | ||
const { stdout, stderr } = await execAsync( | ||
'npx --no-install . erd build --input fixtures/input.schema.rb --format schemarb', | ||
) | ||
// NOTE: suppress the following warning: | ||
if ( | ||
!stderr.includes( | ||
'ExperimentalWarning: WASI is an experimental feature and might change at any time', | ||
) | ||
) { | ||
expect(stderr).toBe('') | ||
} | ||
|
||
expect(stdout).toBe(` | ||
ERD has been generated successfully in the \`dist/\` directory. | ||
Note: You cannot open this file directly using \`file://\`. | ||
Please serve the \`dist/\` directory with an HTTP server and access it via \`http://\`. | ||
Example: | ||
${blueBright('$ npx http-server dist/')} | ||
`) | ||
|
||
const { stdout: lsOutput } = await execAsync('ls ./dist') | ||
expect(lsOutput.trim().length).toBeGreaterThan(0) | ||
} catch (error) { | ||
console.error(error) | ||
// Fail the test if an error occurs | ||
expect(error).toBeNull() | ||
} finally { | ||
await execAsync('rm -rf ./dist') | ||
} | ||
}, 20000 /* 20 seconds for smoke test */) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
68 changes: 38 additions & 30 deletions
68
frontend/packages/db-structure/src/parser/sql/postgresql/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,57 +1,65 @@ | ||
import type { DBStructure } from '../../../schema/index.js' | ||
import type { ProcessError } from '../../errors.js' | ||
import { type ProcessError, UnexpectedTokenWarningError } from '../../errors.js' | ||
import type { Processor } from '../../types.js' | ||
import { convertToDBStructure } from './converter.js' | ||
import { mergeDBStructures } from './mergeDBStructures.js' | ||
import { parse } from './parser.js' | ||
import { processSQLInChunks } from './processSQLInChunks.js' | ||
|
||
export const processor: Processor = async (str: string) => { | ||
const dbStructure: DBStructure = { tables: {}, relationships: {} } | ||
/** | ||
* Processes SQL statements and constructs a database structure. | ||
*/ | ||
export const processor: Processor = async (sql: string) => { | ||
const dbSchema: DBStructure = { tables: {}, relationships: {} } | ||
const CHUNK_SIZE = 500 | ||
const errors: ProcessError[] = [] | ||
const parseErrors: ProcessError[] = [] | ||
|
||
const errors = await processSQLInChunks(sql, CHUNK_SIZE, async (chunk) => { | ||
let readOffset: number | null = null | ||
let errorOffset: number | null = null | ||
const errors: ProcessError[] = [] | ||
|
||
await processSQLInChunks(str, CHUNK_SIZE, async (chunk) => { | ||
let readPosition: number | null = null | ||
let errorPosition: number | null = null | ||
const { parse_tree, error: parseError } = await parse(chunk) | ||
|
||
if (parse_tree.stmts.length > 0) { | ||
if (parseError !== null) { | ||
throw new Error('UnexpectedCondition') | ||
} | ||
if (parse_tree.stmts.length > 0 && parseError !== null) { | ||
throw new Error('UnexpectedCondition') | ||
} | ||
|
||
if (parseError !== null) { | ||
errorPosition = parseError.cursorpos | ||
// TODO: save error message | ||
return [errorPosition, readPosition] | ||
errors.push(new UnexpectedTokenWarningError(parseError.message)) | ||
errorOffset = parseError.cursorpos | ||
return [errorOffset, readOffset, errors] | ||
} | ||
|
||
let lastStmtCompleted = true | ||
const l = parse_tree.stmts.length | ||
if (l > 0) { | ||
const last = parse_tree.stmts[l - 1] | ||
if (last?.stmt_len === undefined) { | ||
lastStmtCompleted = false | ||
if (last?.stmt_location === undefined) { | ||
let isLastStatementComplete = true | ||
const statementCount = parse_tree.stmts.length | ||
|
||
if (statementCount > 0) { | ||
const lastStmt = parse_tree.stmts[statementCount - 1] | ||
if (lastStmt?.stmt_len === undefined) { | ||
isLastStatementComplete = false | ||
if (lastStmt?.stmt_location === undefined) { | ||
throw new Error('UnexpectedCondition') | ||
} | ||
readPosition = last?.stmt_location - 1 | ||
readOffset = lastStmt?.stmt_location - 1 | ||
} | ||
} | ||
|
||
const { value: converted, errors: convertErrors } = convertToDBStructure( | ||
lastStmtCompleted ? parse_tree.stmts : parse_tree.stmts.slice(0, -1), | ||
) | ||
if (convertErrors !== null) { | ||
errors.push(...convertErrors) | ||
const { value: convertedSchema, errors: conversionErrors } = | ||
convertToDBStructure( | ||
isLastStatementComplete | ||
? parse_tree.stmts | ||
: parse_tree.stmts.slice(0, -1), | ||
) | ||
|
||
if (conversionErrors !== null) { | ||
parseErrors.push(...conversionErrors) | ||
} | ||
|
||
mergeDBStructures(dbStructure, converted) | ||
mergeDBStructures(dbSchema, convertedSchema) | ||
|
||
return [errorPosition, readPosition] | ||
return [errorOffset, readOffset, errors] | ||
}) | ||
|
||
return { value: dbStructure, errors } | ||
return { value: dbSchema, errors: parseErrors.concat(errors) } | ||
} |
51 changes: 51 additions & 0 deletions
51
frontend/packages/db-structure/src/parser/sql/postgresql/processSQLInChunks.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { describe, expect, it, vi } from 'vitest' | ||
import { processSQLInChunks } from './processSQLInChunks.js' | ||
|
||
describe(processSQLInChunks, () => { | ||
describe('processSQLInChunks', () => { | ||
it('should split input by newline and process each chunk', async () => { | ||
const input = 'SELECT 1;\nSELECT 2;\nSELECT 3;' | ||
const chunkSize = 2 | ||
const callback = vi.fn().mockResolvedValue([null, null]) | ||
|
||
await processSQLInChunks(input, chunkSize, callback) | ||
|
||
expect(callback).toHaveBeenCalledTimes(2) | ||
expect(callback).toHaveBeenCalledWith('SELECT 1;\nSELECT 2;') | ||
expect(callback).toHaveBeenCalledWith('SELECT 3;') | ||
}) | ||
|
||
it('should handle chunks correctly to avoid invalid SQL syntax', async () => { | ||
const input = 'SELECT 1;\nSELECT 2;\nSELECT 3;\nSELECT 4;' | ||
const chunkSize = 3 | ||
const callback = vi.fn().mockResolvedValue([null, null]) | ||
|
||
await processSQLInChunks(input, chunkSize, callback) | ||
|
||
expect(callback).toHaveBeenCalledTimes(2) | ||
expect(callback).toHaveBeenCalledWith('SELECT 1;\nSELECT 2;\nSELECT 3;') | ||
expect(callback).toHaveBeenCalledWith('SELECT 4;') | ||
}) | ||
|
||
it('should handle input with no newlines correctly', async () => { | ||
const input = 'SELECT 1; SELECT 2; SELECT 3;' | ||
const chunkSize = 1 | ||
const callback = vi.fn().mockResolvedValue([null, null]) | ||
|
||
await processSQLInChunks(input, chunkSize, callback) | ||
|
||
expect(callback).toHaveBeenCalledTimes(1) | ||
expect(callback).toHaveBeenCalledWith('SELECT 1; SELECT 2; SELECT 3;') | ||
}) | ||
|
||
it('should handle empty input correctly', async () => { | ||
const input = '' | ||
const chunkSize = 1 | ||
const callback = vi.fn().mockResolvedValue([null, null]) | ||
|
||
await processSQLInChunks(input, chunkSize, callback) | ||
|
||
expect(callback).not.toHaveBeenCalled() | ||
}) | ||
}) | ||
}) |
Oops, something went wrong.