Skip to content

Commit

Permalink
avoid handle semicolon 2
Browse files Browse the repository at this point in the history
  • Loading branch information
hoshinotsuyoshi committed Feb 10, 2025
1 parent d714b0f commit e13604a
Show file tree
Hide file tree
Showing 6 changed files with 339 additions and 67 deletions.
92 changes: 92 additions & 0 deletions frontend/packages/cli/src/cli/erdCommand/runPreprocess.test.ts
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"',
),
])
})
})
83 changes: 83 additions & 0 deletions frontend/packages/cli/src/cli/smoke.test.ts
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 */)
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { Table } from '../../../schema/index.js'
import { aColumn, aDBStructure, aTable } from '../../../schema/index.js'
import { createParserTestCases } from '../../__tests__/index.js'
import { UnexpectedTokenWarningError } from '../../errors.js'
import { processor } from './index.js'

describe(processor, () => {
Expand Down Expand Up @@ -238,4 +239,20 @@ describe(processor, () => {
)
})
})

describe('abnormal cases', () => {
// FIXME: more speed up
it('show error if the syntax is broken', async () => {
const result = await processor(/* sql */ `
CREATEe TABLE posts ();
`)

const value = { tables: {}, relationships: {} }
const errors = [
new UnexpectedTokenWarningError('syntax error at or near "CREATEe"'),
]

expect(result).toEqual({ value, errors })
})
})
})
68 changes: 38 additions & 30 deletions frontend/packages/db-structure/src/parser/sql/postgresql/index.ts
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) }
}
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()
})
})
})
Loading

0 comments on commit e13604a

Please sign in to comment.