mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-02 04:53:27 +00:00
feat: add MySQL & MariaDB tests and fix parsing SQL
This commit is contained in:
166
parser-comparison-analysis.md
Normal file
166
parser-comparison-analysis.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# PostgreSQL vs MySQL Parser Comparison Analysis
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document compares how the PostgreSQL and MySQL parsers in ChartDB handle SQL parsing, focusing on the differences that could cause the same SQL file to produce different results.
|
||||||
|
|
||||||
|
## 1. SQL Sanitization and Comment Handling
|
||||||
|
|
||||||
|
### PostgreSQL Parser (`postgresql-improved.ts`)
|
||||||
|
|
||||||
|
#### Comment Removal Strategy:
|
||||||
|
1. **Order**: Comments are removed FIRST, before any other processing
|
||||||
|
2. **Multi-line comments**: Removed using regex: `/\/\*[\s\S]*?\*\//g`
|
||||||
|
3. **Single-line comments**: Removed line-by-line, checking for `--` while respecting string boundaries
|
||||||
|
4. **String-aware**: Preserves `--` inside quoted strings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PostgreSQL approach (lines 60-100)
|
||||||
|
// 1. First removes ALL multi-line comments
|
||||||
|
cleanedSQL = cleanedSQL.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
|
||||||
|
// 2. Then processes single-line comments while respecting strings
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
// Tracks if we're inside a string to avoid removing -- inside quotes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MySQL Parser (`mysql-improved.ts`)
|
||||||
|
|
||||||
|
#### Comment Removal Strategy:
|
||||||
|
1. **Order**: Comments are sanitized but with special handling for problematic patterns
|
||||||
|
2. **Special handling**: Specifically fixes multi-line comments that contain quotes or JSON
|
||||||
|
3. **Line-by-line**: Processes comments line by line, removing lines that start with `--` or `#`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// MySQL approach (lines 35-67)
|
||||||
|
// 1. First fixes specific problematic patterns
|
||||||
|
result = result.replace(/--\s*"[^"]*",?\s*\n\s*"[^"]*".*$/gm, function(match) {
|
||||||
|
return match.replace(/\n/g, ' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Then removes comment lines entirely
|
||||||
|
.map((line) => {
|
||||||
|
if (trimmed.startsWith('--') || trimmed.startsWith('#')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Difference**: PostgreSQL removes ALL comments upfront, while MySQL tries to fix problematic comment patterns first, then removes comment lines.
|
||||||
|
|
||||||
|
## 2. Order of Operations
|
||||||
|
|
||||||
|
### PostgreSQL Parser
|
||||||
|
1. **Preprocess SQL** (removes all comments first)
|
||||||
|
2. **Split statements** by semicolons (handles dollar quotes)
|
||||||
|
3. **Categorize statements** (table, index, alter, etc.)
|
||||||
|
4. **Parse with node-sql-parser**
|
||||||
|
5. **Fallback to regex** if parser fails
|
||||||
|
6. **Extract relationships**
|
||||||
|
|
||||||
|
### MySQL Parser
|
||||||
|
1. **Validate syntax** (checks for known issues)
|
||||||
|
2. **Sanitize SQL** (fixes problematic patterns)
|
||||||
|
3. **Extract statements** by semicolons
|
||||||
|
4. **Parse with node-sql-parser**
|
||||||
|
5. **Fallback to regex** if parser fails
|
||||||
|
6. **Process relationships**
|
||||||
|
|
||||||
|
**Key Difference**: MySQL validates BEFORE sanitizing, while PostgreSQL sanitizes first. This means MySQL can detect and report issues that PostgreSQL might silently fix.
|
||||||
|
|
||||||
|
## 3. Multi-line Comment Handling
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
- Removes ALL multi-line comments using `[\s\S]*?` pattern
|
||||||
|
- No special handling for comments containing quotes or JSON
|
||||||
|
- Clean removal before any parsing
|
||||||
|
|
||||||
|
### MySQL
|
||||||
|
- Specifically detects and fixes multi-line comments with quotes:
|
||||||
|
```sql
|
||||||
|
-- "Beliebt",
|
||||||
|
"Empfohlen" -- This breaks MySQL parser
|
||||||
|
```
|
||||||
|
- Detects JSON arrays in comments spanning lines:
|
||||||
|
```sql
|
||||||
|
-- [
|
||||||
|
"Ubuntu 22.04",
|
||||||
|
"CentOS 8"
|
||||||
|
] -- This also breaks MySQL parser
|
||||||
|
```
|
||||||
|
- Converts these to single-line comments before parsing
|
||||||
|
|
||||||
|
**Key Difference**: MySQL has specific handling for problematic comment patterns that PostgreSQL simply removes entirely.
|
||||||
|
|
||||||
|
## 4. Statement Splitting
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
- Handles PostgreSQL-specific dollar quotes (`$$ ... $$`)
|
||||||
|
- Tracks quote depth for proper splitting
|
||||||
|
- Supports function bodies with dollar quotes
|
||||||
|
|
||||||
|
### MySQL
|
||||||
|
- Simple quote tracking (single, double, backtick)
|
||||||
|
- Handles escape sequences (`\`)
|
||||||
|
- No special quote constructs
|
||||||
|
|
||||||
|
## 5. Validation Approach
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
- No pre-validation
|
||||||
|
- Relies on parser and fallback regex
|
||||||
|
- Reports warnings for unsupported features
|
||||||
|
|
||||||
|
### MySQL
|
||||||
|
- Pre-validates SQL before parsing
|
||||||
|
- Detects known problematic patterns:
|
||||||
|
- Multi-line comments with quotes
|
||||||
|
- JSON arrays in comments
|
||||||
|
- Inline REFERENCES (PostgreSQL syntax)
|
||||||
|
- Missing semicolons
|
||||||
|
- Can reject SQL before attempting to parse
|
||||||
|
|
||||||
|
## 6. Why Same SQL Gives Different Results
|
||||||
|
|
||||||
|
### Example Problematic SQL:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
status VARCHAR(50), -- "active",
|
||||||
|
"inactive", "pending"
|
||||||
|
data JSON -- [
|
||||||
|
{"key": "value"},
|
||||||
|
{"key": "value2"}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL Result:
|
||||||
|
- Successfully parses (comments are removed entirely)
|
||||||
|
- Table created with proper columns
|
||||||
|
|
||||||
|
### MySQL Result:
|
||||||
|
- Validation fails with errors:
|
||||||
|
- MULTILINE_COMMENT_QUOTE at line 3
|
||||||
|
- MULTILINE_JSON_COMMENT at line 5
|
||||||
|
- Import blocked unless validation is skipped
|
||||||
|
|
||||||
|
## 7. Recommendations
|
||||||
|
|
||||||
|
1. **For Cross-Database Compatibility**:
|
||||||
|
- Avoid multi-line comments with quotes or JSON
|
||||||
|
- Keep comments on single lines
|
||||||
|
- Use proper FOREIGN KEY syntax instead of inline REFERENCES
|
||||||
|
|
||||||
|
2. **For MySQL Import**:
|
||||||
|
- Fix validation errors before import
|
||||||
|
- Or use `skipValidation: true` option if SQL is known to work
|
||||||
|
|
||||||
|
3. **For PostgreSQL Import**:
|
||||||
|
- Be aware that comments are stripped entirely
|
||||||
|
- Complex comments might hide syntax issues
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The main difference is that PostgreSQL takes a "remove all comments first" approach, while MySQL tries to detect and handle problematic comment patterns. This makes PostgreSQL more forgiving but MySQL more explicit about potential issues. The same SQL file can succeed in PostgreSQL but fail in MySQL if it contains multi-line comments with special characters.
|
||||||
@@ -37,10 +37,8 @@ import { InstructionsSection } from './instructions-section/instructions-section
|
|||||||
import { parseSQLError } from '@/lib/data/sql-import';
|
import { parseSQLError } from '@/lib/data/sql-import';
|
||||||
import type * as monaco from 'monaco-editor';
|
import type * as monaco from 'monaco-editor';
|
||||||
import { waitFor } from '@/lib/utils';
|
import { waitFor } from '@/lib/utils';
|
||||||
import {
|
import { type ValidationResult } from '@/lib/data/sql-import/sql-validator';
|
||||||
validatePostgreSQLSyntax,
|
import { validateSQL } from '@/lib/data/sql-import/unified-sql-validator';
|
||||||
type ValidationResult,
|
|
||||||
} from '@/lib/data/sql-import/sql-validator';
|
|
||||||
import { SQLValidationStatus } from './sql-validation-status';
|
import { SQLValidationStatus } from './sql-validation-status';
|
||||||
|
|
||||||
const errorScriptOutputMessage =
|
const errorScriptOutputMessage =
|
||||||
@@ -157,8 +155,8 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First run our validation
|
// First run our validation based on database type
|
||||||
const validation = validatePostgreSQLSyntax(scriptResult);
|
const validation = validateSQL(scriptResult, databaseType);
|
||||||
setSqlValidation(validation);
|
setSqlValidation(validation);
|
||||||
|
|
||||||
// If we have auto-fixable errors, show the auto-fix button
|
// If we have auto-fixable errors, show the auto-fix button
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fromMySQL } from '../mysql';
|
||||||
|
import { sqlImportToDiagram, detectDatabaseType } from '../../../index';
|
||||||
|
import { DatabaseType } from '@/lib/domain/database-type';
|
||||||
|
|
||||||
|
describe('MariaDB Integration', () => {
|
||||||
|
it('should detect MariaDB from SQL dump', () => {
|
||||||
|
const mariaDbSql = `
|
||||||
|
-- MariaDB dump 10.19 Distrib 10.11.2-MariaDB, for Linux (x86_64)
|
||||||
|
--
|
||||||
|
-- Host: localhost Database: fantasy_db
|
||||||
|
-- ------------------------------------------------------
|
||||||
|
-- Server version 10.11.2-MariaDB
|
||||||
|
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||||
|
/*!40101 SET NAMES utf8mb4 */;
|
||||||
|
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||||
|
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||||
|
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||||
|
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||||
|
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||||
|
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||||
|
|
||||||
|
CREATE TABLE magic_realms (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;`;
|
||||||
|
|
||||||
|
const detectedType = detectDatabaseType(mariaDbSql);
|
||||||
|
expect(detectedType).toBe(DatabaseType.MARIADB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse MariaDB SQL using MySQL parser', async () => {
|
||||||
|
const mariaDbSql = `
|
||||||
|
CREATE TABLE wizards (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
power_level INT DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE spells (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
wizard_id INT NOT NULL,
|
||||||
|
mana_cost INT DEFAULT 10,
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`;
|
||||||
|
|
||||||
|
const result = await fromMySQL(mariaDbSql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(2);
|
||||||
|
expect(result.relationships).toHaveLength(1);
|
||||||
|
|
||||||
|
const wizards = result.tables.find((t) => t.name === 'wizards');
|
||||||
|
expect(wizards?.columns).toHaveLength(5);
|
||||||
|
|
||||||
|
const fk = result.relationships[0];
|
||||||
|
expect(fk.sourceTable).toBe('spells');
|
||||||
|
expect(fk.targetTable).toBe('wizards');
|
||||||
|
expect(fk.deleteAction).toBe('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle MariaDB-specific storage engines', async () => {
|
||||||
|
const mariaDbSql = `
|
||||||
|
-- Using Aria storage engine (MariaDB specific)
|
||||||
|
CREATE TABLE magical_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=Aria DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Using ColumnStore engine (MariaDB specific)
|
||||||
|
CREATE TABLE spell_analytics (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
spell_name VARCHAR(200),
|
||||||
|
cast_count BIGINT DEFAULT 0,
|
||||||
|
avg_mana_cost DECIMAL(10,2)
|
||||||
|
) ENGINE=COLUMNSTORE DEFAULT CHARSET=utf8mb4;`;
|
||||||
|
|
||||||
|
const result = await fromMySQL(mariaDbSql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
result.tables.find((t) => t.name === 'magical_logs')
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
result.tables.find((t) => t.name === 'spell_analytics')
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle MariaDB-specific data types', async () => {
|
||||||
|
const mariaDbSql = `
|
||||||
|
CREATE TABLE advanced_spells (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
spell_id UUID, -- MariaDB has native UUID type
|
||||||
|
spell_data JSON, -- JSON support
|
||||||
|
cast_location POINT, -- Geometry type
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB;`;
|
||||||
|
|
||||||
|
const result = await fromMySQL(mariaDbSql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(1);
|
||||||
|
const table = result.tables[0];
|
||||||
|
expect(table.columns).toHaveLength(5);
|
||||||
|
|
||||||
|
const uuidCol = table.columns.find((c) => c.name === 'spell_id');
|
||||||
|
expect(uuidCol?.type).toBe('UUID');
|
||||||
|
|
||||||
|
const jsonCol = table.columns.find((c) => c.name === 'spell_data');
|
||||||
|
expect(jsonCol?.type).toBe('JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with sqlImportToDiagram for MariaDB', async () => {
|
||||||
|
const mariaDbSql = `
|
||||||
|
/*!100100 SET @@SQL_MODE='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION' */;
|
||||||
|
|
||||||
|
CREATE TABLE dragon_riders (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
dragon_count INT DEFAULT 0
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE dragons (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
rider_id INT,
|
||||||
|
FOREIGN KEY (rider_id) REFERENCES dragon_riders(id)
|
||||||
|
) ENGINE=InnoDB;`;
|
||||||
|
|
||||||
|
const diagram = await sqlImportToDiagram({
|
||||||
|
sqlContent: mariaDbSql,
|
||||||
|
sourceDatabaseType: DatabaseType.MARIADB,
|
||||||
|
targetDatabaseType: DatabaseType.GENERIC,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diagram.tables).toHaveLength(2);
|
||||||
|
expect(diagram.relationships).toHaveLength(1);
|
||||||
|
|
||||||
|
// Check that tables are properly sorted
|
||||||
|
expect(diagram.tables[0].name).toBe('dragon_riders');
|
||||||
|
expect(diagram.tables[1].name).toBe('dragons');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fromMySQLImproved } from '../mysql-improved';
|
||||||
|
import { fromMySQL } from '../mysql';
|
||||||
|
|
||||||
|
describe('MySQL Core Functionality', () => {
|
||||||
|
describe('Basic Table Parsing', () => {
|
||||||
|
it('should parse a simple table', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(1);
|
||||||
|
expect(result.tables[0].name).toBe('users');
|
||||||
|
expect(result.tables[0].columns).toHaveLength(4);
|
||||||
|
|
||||||
|
const idColumn = result.tables[0].columns.find(
|
||||||
|
(c) => c.name === 'id'
|
||||||
|
);
|
||||||
|
expect(idColumn?.primaryKey).toBe(true);
|
||||||
|
expect(idColumn?.increment).toBe(true);
|
||||||
|
|
||||||
|
const emailColumn = result.tables[0].columns.find(
|
||||||
|
(c) => c.name === 'email'
|
||||||
|
);
|
||||||
|
expect(emailColumn?.unique).toBe(true);
|
||||||
|
expect(emailColumn?.nullable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse tables with backticks', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE \`user-profiles\` (
|
||||||
|
\`user-id\` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
\`full-name\` VARCHAR(255) NOT NULL,
|
||||||
|
\`bio-text\` TEXT
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(1);
|
||||||
|
expect(result.tables[0].name).toBe('user-profiles');
|
||||||
|
expect(
|
||||||
|
result.tables[0].columns.some((c) => c.name === 'user-id')
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
result.tables[0].columns.some((c) => c.name === 'full-name')
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle IF NOT EXISTS clause', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(1);
|
||||||
|
expect(result.tables[0].name).toBe('products');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Data Types', () => {
|
||||||
|
it('should parse various MySQL data types', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE data_types_test (
|
||||||
|
col_tinyint TINYINT,
|
||||||
|
col_smallint SMALLINT,
|
||||||
|
col_mediumint MEDIUMINT,
|
||||||
|
col_int INT(11),
|
||||||
|
col_bigint BIGINT,
|
||||||
|
col_decimal DECIMAL(10,2),
|
||||||
|
col_float FLOAT,
|
||||||
|
col_double DOUBLE,
|
||||||
|
col_bit BIT(8),
|
||||||
|
col_char CHAR(10),
|
||||||
|
col_varchar VARCHAR(255),
|
||||||
|
col_binary BINARY(16),
|
||||||
|
col_varbinary VARBINARY(255),
|
||||||
|
col_tinytext TINYTEXT,
|
||||||
|
col_text TEXT,
|
||||||
|
col_mediumtext MEDIUMTEXT,
|
||||||
|
col_longtext LONGTEXT,
|
||||||
|
col_tinyblob TINYBLOB,
|
||||||
|
col_blob BLOB,
|
||||||
|
col_mediumblob MEDIUMBLOB,
|
||||||
|
col_longblob LONGBLOB,
|
||||||
|
col_date DATE,
|
||||||
|
col_datetime DATETIME,
|
||||||
|
col_timestamp TIMESTAMP,
|
||||||
|
col_time TIME,
|
||||||
|
col_year YEAR,
|
||||||
|
col_enum ENUM('small', 'medium', 'large'),
|
||||||
|
col_set SET('read', 'write', 'execute'),
|
||||||
|
col_json JSON,
|
||||||
|
col_geometry GEOMETRY,
|
||||||
|
col_point POINT
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(1);
|
||||||
|
const table = result.tables[0];
|
||||||
|
|
||||||
|
expect(table.columns.find((c) => c.name === 'col_int')?.type).toBe(
|
||||||
|
'INT'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
table.columns.find((c) => c.name === 'col_varchar')?.type
|
||||||
|
).toBe('VARCHAR');
|
||||||
|
expect(
|
||||||
|
table.columns.find((c) => c.name === 'col_decimal')?.type
|
||||||
|
).toBe('DECIMAL');
|
||||||
|
expect(table.columns.find((c) => c.name === 'col_enum')?.type).toBe(
|
||||||
|
'ENUM'
|
||||||
|
);
|
||||||
|
expect(table.columns.find((c) => c.name === 'col_json')?.type).toBe(
|
||||||
|
'JSON'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Constraints', () => {
|
||||||
|
it('should parse PRIMARY KEY constraints', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE pk_test (
|
||||||
|
id INT,
|
||||||
|
code VARCHAR(10),
|
||||||
|
PRIMARY KEY (id, code)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
const table = result.tables[0];
|
||||||
|
expect(table.columns.find((c) => c.name === 'id')?.primaryKey).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
table.columns.find((c) => c.name === 'code')?.primaryKey
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
table.indexes.some(
|
||||||
|
(idx) =>
|
||||||
|
idx.name === 'pk_pk_test' &&
|
||||||
|
idx.columns.includes('id') &&
|
||||||
|
idx.columns.includes('code')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse UNIQUE constraints', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE unique_test (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
email VARCHAR(255),
|
||||||
|
username VARCHAR(100),
|
||||||
|
UNIQUE KEY uk_email (email),
|
||||||
|
UNIQUE KEY uk_username_email (username, email)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
const table = result.tables[0];
|
||||||
|
expect(
|
||||||
|
table.indexes.some(
|
||||||
|
(idx) => idx.name === 'uk_email' && idx.unique === true
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
table.indexes.some(
|
||||||
|
(idx) =>
|
||||||
|
idx.name === 'uk_username_email' &&
|
||||||
|
idx.unique === true &&
|
||||||
|
idx.columns.length === 2
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse CHECK constraints', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE check_test (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
age INT CHECK (age >= 18),
|
||||||
|
price DECIMAL(10,2) CHECK (price > 0)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(1);
|
||||||
|
expect(result.tables[0].columns).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Foreign Keys', () => {
|
||||||
|
it('should parse inline FOREIGN KEY constraints', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE departments (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE employees (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
department_id INT,
|
||||||
|
FOREIGN KEY (department_id) REFERENCES departments(id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(2);
|
||||||
|
expect(result.relationships).toHaveLength(1);
|
||||||
|
|
||||||
|
const fk = result.relationships[0];
|
||||||
|
expect(fk.sourceTable).toBe('employees');
|
||||||
|
expect(fk.sourceColumn).toBe('department_id');
|
||||||
|
expect(fk.targetTable).toBe('departments');
|
||||||
|
expect(fk.targetColumn).toBe('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse named FOREIGN KEY constraints', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
customer_id INT NOT NULL,
|
||||||
|
CONSTRAINT fk_order_customer FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.relationships).toHaveLength(1);
|
||||||
|
|
||||||
|
const fk = result.relationships[0];
|
||||||
|
expect(fk.name).toBe('fk_order_customer');
|
||||||
|
expect(fk.deleteAction).toBe('CASCADE');
|
||||||
|
expect(fk.updateAction).toBe('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ALTER TABLE ADD FOREIGN KEY', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE reviews (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
rating INT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE reviews
|
||||||
|
ADD CONSTRAINT fk_review_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(2);
|
||||||
|
expect(result.relationships).toHaveLength(1);
|
||||||
|
|
||||||
|
const fk = result.relationships[0];
|
||||||
|
expect(fk.sourceTable).toBe('reviews');
|
||||||
|
expect(fk.targetTable).toBe('products');
|
||||||
|
expect(fk.deleteAction).toBe('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle composite foreign keys', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE tenants (
|
||||||
|
id INT NOT NULL,
|
||||||
|
region VARCHAR(10) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
PRIMARY KEY (id, region)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tenant_settings (
|
||||||
|
tenant_id INT NOT NULL,
|
||||||
|
tenant_region VARCHAR(10) NOT NULL,
|
||||||
|
setting_key VARCHAR(100) NOT NULL,
|
||||||
|
setting_value TEXT,
|
||||||
|
PRIMARY KEY (tenant_id, tenant_region, setting_key),
|
||||||
|
FOREIGN KEY (tenant_id, tenant_region) REFERENCES tenants(id, region)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.relationships).toHaveLength(2);
|
||||||
|
|
||||||
|
const fk1 = result.relationships.find(
|
||||||
|
(r) => r.sourceColumn === 'tenant_id'
|
||||||
|
);
|
||||||
|
const fk2 = result.relationships.find(
|
||||||
|
(r) => r.sourceColumn === 'tenant_region'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fk1?.targetColumn).toBe('id');
|
||||||
|
expect(fk2?.targetColumn).toBe('region');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Indexes', () => {
|
||||||
|
it('should parse CREATE INDEX statements', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
category VARCHAR(100),
|
||||||
|
price DECIMAL(10,2)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_category ON products(category);
|
||||||
|
CREATE UNIQUE INDEX idx_name ON products(name);
|
||||||
|
CREATE INDEX idx_category_price ON products(category, price);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
const table = result.tables[0];
|
||||||
|
expect(
|
||||||
|
table.indexes.some(
|
||||||
|
(idx) => idx.name === 'idx_category' && !idx.unique
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
table.indexes.some(
|
||||||
|
(idx) => idx.name === 'idx_name' && idx.unique
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
table.indexes.some(
|
||||||
|
(idx) =>
|
||||||
|
idx.name === 'idx_category_price' &&
|
||||||
|
idx.columns.length === 2
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse inline INDEX definitions', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_email (email),
|
||||||
|
UNIQUE INDEX uk_username (username),
|
||||||
|
INDEX idx_created (created_at DESC)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
const table = result.tables[0];
|
||||||
|
expect(table.indexes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Table Options', () => {
|
||||||
|
it('should handle ENGINE and CHARSET options', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE logs (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
message TEXT
|
||||||
|
) ENGINE=MyISAM DEFAULT CHARSET=latin1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(2);
|
||||||
|
expect(result.tables[0].name).toBe('products');
|
||||||
|
expect(result.tables[1].name).toBe('logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle AUTO_INCREMENT initial value', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_number VARCHAR(50) NOT NULL
|
||||||
|
) AUTO_INCREMENT=1000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(1);
|
||||||
|
const idColumn = result.tables[0].columns.find(
|
||||||
|
(c) => c.name === 'id'
|
||||||
|
);
|
||||||
|
expect(idColumn?.increment).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should reject inline REFERENCES (PostgreSQL style)', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
department_id INT REFERENCES departments(id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Using the original parser which checks for inline REFERENCES
|
||||||
|
await expect(fromMySQL(sql)).rejects.toThrow(
|
||||||
|
/MySQL\/MariaDB does not support inline REFERENCES/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed SQL gracefully', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE test (
|
||||||
|
id INT PRIMARY KEY
|
||||||
|
name VARCHAR(255) -- missing comma
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
// Should still create a table with fallback parsing
|
||||||
|
expect(result.tables.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Comments and Special Cases', () => {
|
||||||
|
it('should handle SQL comments', async () => {
|
||||||
|
const sql = `
|
||||||
|
-- This is a comment
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT PRIMARY KEY, -- user identifier
|
||||||
|
/* Multi-line comment
|
||||||
|
spanning multiple lines */
|
||||||
|
name VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
# MySQL-style comment
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id INT PRIMARY KEY
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(2);
|
||||||
|
expect(result.tables.map((t) => t.name).sort()).toEqual([
|
||||||
|
'posts',
|
||||||
|
'users',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty or whitespace-only input', async () => {
|
||||||
|
const result1 = await fromMySQLImproved('');
|
||||||
|
expect(result1.tables).toHaveLength(0);
|
||||||
|
expect(result1.relationships).toHaveLength(0);
|
||||||
|
|
||||||
|
const result2 = await fromMySQLImproved(' \n\n ');
|
||||||
|
expect(result2.tables).toHaveLength(0);
|
||||||
|
expect(result2.relationships).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fromMySQLImproved } from '../mysql-improved';
|
||||||
|
|
||||||
|
describe('MySQL Real-World Examples', () => {
|
||||||
|
describe('Magical Academy Example', () => {
|
||||||
|
it('should parse the magical academy example with all 16 tables', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE schools(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE towers(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ranks(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE spell_permissions(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
spell_type VARCHAR(100) NOT NULL,
|
||||||
|
casting_level VARCHAR(50) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE rank_spell_permissions(
|
||||||
|
rank_id INT NOT NULL,
|
||||||
|
spell_permission_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (rank_id, spell_permission_id),
|
||||||
|
FOREIGN KEY (rank_id) REFERENCES ranks(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (spell_permission_id) REFERENCES spell_permissions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE grimoire_types(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE wizards(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT NOT NULL,
|
||||||
|
tower_id INT NOT NULL,
|
||||||
|
wizard_name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
UNIQUE KEY school_wizard_unique (school_id, wizard_name),
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE wizard_ranks(
|
||||||
|
wizard_id INT NOT NULL,
|
||||||
|
rank_id INT NOT NULL,
|
||||||
|
tower_id INT NOT NULL,
|
||||||
|
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (wizard_id, rank_id, tower_id),
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (rank_id) REFERENCES ranks(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE apprentices(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT NOT NULL,
|
||||||
|
tower_id INT NOT NULL,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
enrollment_date DATE NOT NULL,
|
||||||
|
primary_mentor INT,
|
||||||
|
sponsoring_wizard INT,
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (primary_mentor) REFERENCES wizards(id),
|
||||||
|
FOREIGN KEY (sponsoring_wizard) REFERENCES wizards(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE spell_lessons(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT NOT NULL,
|
||||||
|
tower_id INT NOT NULL,
|
||||||
|
apprentice_id INT NOT NULL,
|
||||||
|
instructor_id INT NOT NULL,
|
||||||
|
lesson_date DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (apprentice_id) REFERENCES apprentices(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (instructor_id) REFERENCES wizards(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE grimoires(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT NOT NULL,
|
||||||
|
tower_id INT NOT NULL,
|
||||||
|
apprentice_id INT NOT NULL,
|
||||||
|
grimoire_type_id INT NOT NULL,
|
||||||
|
author_wizard_id INT NOT NULL,
|
||||||
|
content JSON NOT NULL,
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (apprentice_id) REFERENCES apprentices(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (grimoire_type_id) REFERENCES grimoire_types(id),
|
||||||
|
FOREIGN KEY (author_wizard_id) REFERENCES wizards(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tuition_scrolls(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT NOT NULL,
|
||||||
|
tower_id INT NOT NULL,
|
||||||
|
apprentice_id INT NOT NULL,
|
||||||
|
total_amount DECIMAL(10,2) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (apprentice_id) REFERENCES apprentices(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tuition_items(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
tuition_scroll_id INT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
FOREIGN KEY (tuition_scroll_id) REFERENCES tuition_scrolls(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE patron_sponsorships(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
tuition_scroll_id INT NOT NULL,
|
||||||
|
patron_house VARCHAR(255) NOT NULL,
|
||||||
|
sponsorship_code VARCHAR(100) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
FOREIGN KEY (tuition_scroll_id) REFERENCES tuition_scrolls(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE gold_payments(
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
tuition_scroll_id INT NOT NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
payment_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (tuition_scroll_id) REFERENCES tuition_scrolls(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE arcane_logs(
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
school_id INT,
|
||||||
|
wizard_id INT,
|
||||||
|
tower_id INT,
|
||||||
|
table_name VARCHAR(100) NOT NULL,
|
||||||
|
operation VARCHAR(50) NOT NULL,
|
||||||
|
record_id INT,
|
||||||
|
changes JSON,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
// Should find all 16 tables
|
||||||
|
const expectedTables = [
|
||||||
|
'apprentices',
|
||||||
|
'arcane_logs',
|
||||||
|
'gold_payments',
|
||||||
|
'grimoire_types',
|
||||||
|
'grimoires',
|
||||||
|
'patron_sponsorships',
|
||||||
|
'rank_spell_permissions',
|
||||||
|
'ranks',
|
||||||
|
'schools',
|
||||||
|
'spell_lessons',
|
||||||
|
'spell_permissions',
|
||||||
|
'towers',
|
||||||
|
'tuition_items',
|
||||||
|
'tuition_scrolls',
|
||||||
|
'wizard_ranks',
|
||||||
|
'wizards',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(16);
|
||||||
|
expect(result.tables.map((t) => t.name).sort()).toEqual(
|
||||||
|
expectedTables
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify key relationships exist
|
||||||
|
const relationships = result.relationships;
|
||||||
|
|
||||||
|
// Check some critical relationships
|
||||||
|
expect(
|
||||||
|
relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'wizards' &&
|
||||||
|
r.targetTable === 'schools' &&
|
||||||
|
r.sourceColumn === 'school_id'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'wizard_ranks' &&
|
||||||
|
r.targetTable === 'wizards' &&
|
||||||
|
r.sourceColumn === 'wizard_id'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'apprentices' &&
|
||||||
|
r.targetTable === 'wizards' &&
|
||||||
|
r.sourceColumn === 'primary_mentor'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Enchanted Bazaar Example', () => {
|
||||||
|
it('should parse the enchanted bazaar example with triggers and procedures', async () => {
|
||||||
|
const sql = `
|
||||||
|
-- Enchanted Bazaar tables with complex features
|
||||||
|
CREATE TABLE merchants(
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE artifacts(
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
merchant_id INT,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
|
||||||
|
enchantment_charges INT DEFAULT 0 CHECK (enchantment_charges >= 0),
|
||||||
|
FOREIGN KEY (merchant_id) REFERENCES merchants(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stored procedure that should be skipped
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE consume_charges(IN artifact_id INT, IN charges_used INT)
|
||||||
|
BEGIN
|
||||||
|
UPDATE artifacts SET enchantment_charges = enchantment_charges - charges_used WHERE id = artifact_id;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CREATE TABLE trades(
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status VARCHAR(50) DEFAULT 'negotiating'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE trade_items(
|
||||||
|
trade_id INT,
|
||||||
|
artifact_id INT,
|
||||||
|
quantity INT NOT NULL CHECK (quantity > 0),
|
||||||
|
agreed_price DECIMAL(10, 2) NOT NULL,
|
||||||
|
PRIMARY KEY (trade_id, artifact_id),
|
||||||
|
FOREIGN KEY (trade_id) REFERENCES trades(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (artifact_id) REFERENCES artifacts(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create trigger
|
||||||
|
CREATE TRIGGER charge_consumption_trigger
|
||||||
|
AFTER INSERT ON trade_items
|
||||||
|
FOR EACH ROW
|
||||||
|
CALL consume_charges(NEW.artifact_id, NEW.quantity);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql, {
|
||||||
|
includeWarnings: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should parse all tables despite procedures and triggers
|
||||||
|
expect(result.tables.length).toBeGreaterThanOrEqual(4);
|
||||||
|
|
||||||
|
// Check for specific tables
|
||||||
|
const tableNames = result.tables.map((t) => t.name);
|
||||||
|
expect(tableNames).toContain('merchants');
|
||||||
|
expect(tableNames).toContain('artifacts');
|
||||||
|
expect(tableNames).toContain('trades');
|
||||||
|
expect(tableNames).toContain('trade_items');
|
||||||
|
|
||||||
|
// Check relationships
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'artifacts' &&
|
||||||
|
r.targetTable === 'merchants'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'trade_items' &&
|
||||||
|
r.targetTable === 'trades'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Should have warnings about unsupported features
|
||||||
|
if (result.warnings) {
|
||||||
|
expect(
|
||||||
|
result.warnings.some(
|
||||||
|
(w) =>
|
||||||
|
w.includes('procedure') ||
|
||||||
|
w.includes('function') ||
|
||||||
|
w.includes('Trigger') ||
|
||||||
|
w.includes('trigger')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dragon Registry Example', () => {
|
||||||
|
it('should parse dragon registry with mixed constraint styles', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE dragon_species (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
species_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
breath_type ENUM('fire', 'ice', 'lightning', 'acid', 'poison') NOT NULL,
|
||||||
|
max_wingspan DECIMAL(5,2),
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE dragon_habitats (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
habitat_name VARCHAR(200) NOT NULL,
|
||||||
|
location_type VARCHAR(50) NOT NULL,
|
||||||
|
climate VARCHAR(50),
|
||||||
|
INDEX idx_location (location_type)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE dragons (
|
||||||
|
dragon_id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
dragon_name VARCHAR(255) NOT NULL,
|
||||||
|
species_id INT NOT NULL,
|
||||||
|
habitat_id INT,
|
||||||
|
birth_year INT,
|
||||||
|
treasure_value DECIMAL(15,2) DEFAULT 0.00,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
PRIMARY KEY (dragon_id),
|
||||||
|
CONSTRAINT fk_dragon_species FOREIGN KEY (species_id) REFERENCES dragon_species(id),
|
||||||
|
CONSTRAINT fk_dragon_habitat FOREIGN KEY (habitat_id) REFERENCES dragon_habitats(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_species (species_id),
|
||||||
|
INDEX idx_active_dragons (is_active, species_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE dragon_riders (
|
||||||
|
rider_id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
rider_name VARCHAR(255) NOT NULL,
|
||||||
|
guild_membership VARCHAR(100),
|
||||||
|
years_experience INT DEFAULT 0,
|
||||||
|
PRIMARY KEY (rider_id),
|
||||||
|
UNIQUE KEY uk_rider_name (rider_name)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE dragon_bonds (
|
||||||
|
bond_id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
dragon_id INT NOT NULL,
|
||||||
|
rider_id INT NOT NULL,
|
||||||
|
bond_date DATE NOT NULL,
|
||||||
|
bond_strength ENUM('weak', 'moderate', 'strong', 'unbreakable') DEFAULT 'weak',
|
||||||
|
PRIMARY KEY (bond_id),
|
||||||
|
UNIQUE KEY unique_dragon_rider (dragon_id, rider_id),
|
||||||
|
FOREIGN KEY (dragon_id) REFERENCES dragons(dragon_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (rider_id) REFERENCES dragon_riders(rider_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(5);
|
||||||
|
|
||||||
|
const tableNames = result.tables.map((t) => t.name).sort();
|
||||||
|
expect(tableNames).toEqual([
|
||||||
|
'dragon_bonds',
|
||||||
|
'dragon_habitats',
|
||||||
|
'dragon_riders',
|
||||||
|
'dragon_species',
|
||||||
|
'dragons',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check that ENUMs were parsed correctly
|
||||||
|
const dragonSpecies = result.tables.find(
|
||||||
|
(t) => t.name === 'dragon_species'
|
||||||
|
);
|
||||||
|
const breathTypeColumn = dragonSpecies?.columns.find(
|
||||||
|
(c) => c.name === 'breath_type'
|
||||||
|
);
|
||||||
|
expect(breathTypeColumn?.type).toBe('ENUM');
|
||||||
|
|
||||||
|
// Check indexes
|
||||||
|
const dragonsTable = result.tables.find(
|
||||||
|
(t) => t.name === 'dragons'
|
||||||
|
);
|
||||||
|
expect(dragonsTable?.indexes.length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
dragonsTable?.indexes.some((idx) => idx.name === 'idx_species')
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Check relationships
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'dragons' &&
|
||||||
|
r.targetTable === 'dragon_species' &&
|
||||||
|
r.sourceColumn === 'species_id'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'dragon_bonds' &&
|
||||||
|
r.targetTable === 'dragons' &&
|
||||||
|
r.deleteAction === 'CASCADE'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mystic Marketplace Example with Backticks', () => {
|
||||||
|
it('should handle tables with backticks and special characters', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE \`marketplace-vendors\` (
|
||||||
|
\`vendor-id\` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
\`vendor name\` VARCHAR(255) NOT NULL,
|
||||||
|
\`shop.location\` VARCHAR(500),
|
||||||
|
\`rating%\` DECIMAL(3,2),
|
||||||
|
PRIMARY KEY (\`vendor-id\`)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE \`item_categories\` (
|
||||||
|
\`category-id\` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
\`category@name\` VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
\`parent_category\` INT,
|
||||||
|
FOREIGN KEY (\`parent_category\`) REFERENCES \`item_categories\`(\`category-id\`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE \`magical.items\` (
|
||||||
|
\`item#id\` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
\`item-name\` VARCHAR(255) NOT NULL,
|
||||||
|
\`vendor-id\` INT NOT NULL,
|
||||||
|
\`category-id\` INT NOT NULL,
|
||||||
|
\`price$gold\` DECIMAL(10,2) NOT NULL,
|
||||||
|
PRIMARY KEY (\`item#id\`),
|
||||||
|
CONSTRAINT \`fk_item_vendor\` FOREIGN KEY (\`vendor-id\`) REFERENCES \`marketplace-vendors\`(\`vendor-id\`),
|
||||||
|
CONSTRAINT \`fk_item_category\` FOREIGN KEY (\`category-id\`) REFERENCES \`item_categories\`(\`category-id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(3);
|
||||||
|
|
||||||
|
// Check that backtick-wrapped names are preserved
|
||||||
|
const vendor = result.tables.find(
|
||||||
|
(t) => t.name === 'marketplace-vendors'
|
||||||
|
);
|
||||||
|
expect(vendor).toBeDefined();
|
||||||
|
expect(vendor?.columns.some((c) => c.name === 'vendor-id')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(vendor?.columns.some((c) => c.name === 'vendor name')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check self-referencing foreign key
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'item_categories' &&
|
||||||
|
r.targetTable === 'item_categories' &&
|
||||||
|
r.sourceColumn === 'parent_category'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Check cross-table relationships
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'magical.items' &&
|
||||||
|
r.targetTable === 'marketplace-vendors'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fromMySQL } from '../mysql';
|
||||||
|
import { fromMySQLImproved } from '../mysql-improved';
|
||||||
|
import { validateMySQLSyntax } from '../mysql-validator';
|
||||||
|
|
||||||
|
describe('MySQL Fantasy World Integration', () => {
|
||||||
|
const fantasyWorldSQL = `
|
||||||
|
-- Fantasy World Database Schema
|
||||||
|
-- A magical realm management system
|
||||||
|
|
||||||
|
-- Realm Management
|
||||||
|
CREATE TABLE realms (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
magic_level ENUM('low', 'medium', 'high', 'legendary') DEFAULT 'medium',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_magic_level (magic_level)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Magical Creatures Registry
|
||||||
|
CREATE TABLE creature_types (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
classification ENUM('beast', 'dragon', 'elemental', 'undead', 'fey', 'construct') NOT NULL,
|
||||||
|
danger_level INT CHECK (danger_level BETWEEN 1 AND 10),
|
||||||
|
is_sentient BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE creatures (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
creature_type_id INT NOT NULL,
|
||||||
|
realm_id INT NOT NULL,
|
||||||
|
health_points INT DEFAULT 100,
|
||||||
|
magic_points INT DEFAULT 50,
|
||||||
|
special_abilities JSON, -- ["fire_breath", "invisibility", "teleportation"]
|
||||||
|
last_sighted DATETIME,
|
||||||
|
status ENUM('active', 'dormant', 'banished', 'deceased') DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (creature_type_id) REFERENCES creature_types(id),
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES realms(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_realm_status (realm_id, status),
|
||||||
|
INDEX idx_type (creature_type_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Wizard Registry
|
||||||
|
CREATE TABLE wizard_ranks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
rank_name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
min_power_level INT NOT NULL,
|
||||||
|
permissions JSON, -- ["cast_forbidden_spells", "access_restricted_library", "mentor_apprentices"]
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE wizards (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
wizard_rank_id INT NOT NULL,
|
||||||
|
realm_id INT NOT NULL,
|
||||||
|
power_level INT DEFAULT 1,
|
||||||
|
specialization ENUM('elemental', 'necromancy', 'illusion', 'healing', 'divination') NOT NULL,
|
||||||
|
familiar_creature_id INT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (wizard_rank_id) REFERENCES wizard_ranks(id),
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES realms(id),
|
||||||
|
FOREIGN KEY (familiar_creature_id) REFERENCES creatures(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_rank (wizard_rank_id),
|
||||||
|
INDEX idx_realm (realm_id),
|
||||||
|
INDEX idx_email (email)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Spell Library
|
||||||
|
CREATE TABLE spell_schools (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
forbidden BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE spells (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
incantation TEXT,
|
||||||
|
spell_school_id INT NOT NULL,
|
||||||
|
mana_cost INT DEFAULT 10,
|
||||||
|
cast_time_seconds INT DEFAULT 3,
|
||||||
|
range_meters INT DEFAULT 10,
|
||||||
|
components JSON, -- ["verbal", "somatic", "material:dragon_scale"]
|
||||||
|
effects JSON, -- {"damage": 50, "duration": 300, "area": "cone"}
|
||||||
|
min_wizard_rank_id INT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (spell_school_id) REFERENCES spell_schools(id),
|
||||||
|
FOREIGN KEY (min_wizard_rank_id) REFERENCES wizard_ranks(id),
|
||||||
|
INDEX idx_school (spell_school_id),
|
||||||
|
FULLTEXT idx_search (name, incantation)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Wizard Spellbooks (many-to-many)
|
||||||
|
CREATE TABLE wizard_spellbooks (
|
||||||
|
wizard_id INT NOT NULL,
|
||||||
|
spell_id INT NOT NULL,
|
||||||
|
learned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
mastery_level INT DEFAULT 1 CHECK (mastery_level BETWEEN 1 AND 5),
|
||||||
|
times_cast INT DEFAULT 0,
|
||||||
|
PRIMARY KEY (wizard_id, spell_id),
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (spell_id) REFERENCES spells(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_spell (spell_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Magical Items
|
||||||
|
CREATE TABLE item_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE magical_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
item_category_id INT NOT NULL,
|
||||||
|
rarity ENUM('common', 'uncommon', 'rare', 'epic', 'legendary', 'artifact') NOT NULL,
|
||||||
|
power_level INT DEFAULT 1,
|
||||||
|
enchantments JSON, -- ["strength+5", "fire_resistance", "invisibility_on_use"]
|
||||||
|
curse_effects JSON, -- ["bound_to_owner", "drains_life", "attracts_monsters"]
|
||||||
|
created_by_wizard_id INT,
|
||||||
|
found_in_realm_id INT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (item_category_id) REFERENCES item_categories(id),
|
||||||
|
FOREIGN KEY (created_by_wizard_id) REFERENCES wizards(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (found_in_realm_id) REFERENCES realms(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_category (item_category_id),
|
||||||
|
INDEX idx_rarity (rarity)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Wizard Inventory
|
||||||
|
CREATE TABLE wizard_inventory (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
wizard_id INT NOT NULL,
|
||||||
|
item_id INT NOT NULL,
|
||||||
|
quantity INT DEFAULT 1,
|
||||||
|
equipped BOOLEAN DEFAULT FALSE,
|
||||||
|
acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES magical_items(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uk_wizard_item (wizard_id, item_id),
|
||||||
|
INDEX idx_wizard (wizard_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Quests and Adventures
|
||||||
|
CREATE TABLE quests (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
realm_id INT NOT NULL,
|
||||||
|
difficulty ENUM('novice', 'adept', 'expert', 'master', 'legendary') NOT NULL,
|
||||||
|
reward_gold INT DEFAULT 0,
|
||||||
|
reward_experience INT DEFAULT 0,
|
||||||
|
reward_items JSON, -- [{"item_id": 1, "quantity": 1}]
|
||||||
|
status ENUM('available', 'in_progress', 'completed', 'failed') DEFAULT 'available',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES realms(id),
|
||||||
|
INDEX idx_realm_status (realm_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Quest Participants
|
||||||
|
CREATE TABLE quest_participants (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
quest_id INT NOT NULL,
|
||||||
|
wizard_id INT NOT NULL,
|
||||||
|
role ENUM('leader', 'member', 'guide', 'support') DEFAULT 'member',
|
||||||
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (quest_id) REFERENCES quests(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uk_quest_wizard (quest_id, wizard_id),
|
||||||
|
INDEX idx_wizard (wizard_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Magical Events Log
|
||||||
|
CREATE TABLE magical_events (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_type ENUM('spell_cast', 'item_created', 'creature_summoned', 'realm_shift', 'quest_completed') NOT NULL,
|
||||||
|
realm_id INT NOT NULL,
|
||||||
|
wizard_id INT,
|
||||||
|
creature_id INT,
|
||||||
|
description TEXT,
|
||||||
|
magic_fluctuation INT DEFAULT 0, -- Positive or negative impact on realm magic
|
||||||
|
event_data JSON, -- Additional event-specific data
|
||||||
|
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES realms(id),
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (creature_id) REFERENCES creatures(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_realm_time (realm_id, occurred_at),
|
||||||
|
INDEX idx_event_type (event_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Wizard Guilds
|
||||||
|
CREATE TABLE guilds (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) UNIQUE NOT NULL,
|
||||||
|
motto TEXT,
|
||||||
|
realm_id INT NOT NULL,
|
||||||
|
founded_by_wizard_id INT,
|
||||||
|
member_count INT DEFAULT 0,
|
||||||
|
guild_hall_location VARCHAR(500),
|
||||||
|
treasury_gold INT DEFAULT 0,
|
||||||
|
founded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES realms(id),
|
||||||
|
FOREIGN KEY (founded_by_wizard_id) REFERENCES wizards(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_realm (realm_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Guild Memberships
|
||||||
|
CREATE TABLE guild_memberships (
|
||||||
|
wizard_id INT NOT NULL,
|
||||||
|
guild_id INT NOT NULL,
|
||||||
|
rank ENUM('apprentice', 'member', 'officer', 'leader') DEFAULT 'member',
|
||||||
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
contribution_points INT DEFAULT 0,
|
||||||
|
PRIMARY KEY (wizard_id, guild_id),
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_guild (guild_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Enchantment Recipes
|
||||||
|
CREATE TABLE enchantment_recipes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
required_spell_ids JSON NOT NULL, -- [1, 5, 12]
|
||||||
|
required_items JSON NOT NULL, -- [{"item_id": 3, "quantity": 2}]
|
||||||
|
result_enchantment VARCHAR(200) NOT NULL,
|
||||||
|
success_rate DECIMAL(5,2) DEFAULT 75.00,
|
||||||
|
created_by_wizard_id INT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by_wizard_id) REFERENCES wizards(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_creator (created_by_wizard_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ALTER TABLE for additional constraints
|
||||||
|
ALTER TABLE creatures ADD CONSTRAINT fk_creature_realm
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES realms(id) ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE magical_items ADD CONSTRAINT unique_artifact_name
|
||||||
|
UNIQUE KEY (name, rarity);
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('Full Fantasy World Schema', () => {
|
||||||
|
it('should parse the complete fantasy world database', async () => {
|
||||||
|
const result = await fromMySQL(fantasyWorldSQL);
|
||||||
|
|
||||||
|
// Verify all tables are parsed
|
||||||
|
expect(result.tables).toHaveLength(17);
|
||||||
|
|
||||||
|
const expectedTables = [
|
||||||
|
'creature_types',
|
||||||
|
'creatures',
|
||||||
|
'enchantment_recipes',
|
||||||
|
'guild_memberships',
|
||||||
|
'guilds',
|
||||||
|
'item_categories',
|
||||||
|
'magical_events',
|
||||||
|
'magical_items',
|
||||||
|
'quest_participants',
|
||||||
|
'quests',
|
||||||
|
'realms',
|
||||||
|
'spell_schools',
|
||||||
|
'spells',
|
||||||
|
'wizard_inventory',
|
||||||
|
'wizard_ranks',
|
||||||
|
'wizard_spellbooks',
|
||||||
|
'wizards',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(result.tables.map((t) => t.name).sort()).toEqual(
|
||||||
|
expectedTables
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify key relationships
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'wizards' &&
|
||||||
|
r.targetTable === 'wizard_ranks' &&
|
||||||
|
r.sourceColumn === 'wizard_rank_id'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'creatures' &&
|
||||||
|
r.targetTable === 'realms' &&
|
||||||
|
r.deleteAction === 'CASCADE'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Verify JSON columns
|
||||||
|
const creatures = result.tables.find((t) => t.name === 'creatures');
|
||||||
|
const abilitiesCol = creatures?.columns.find(
|
||||||
|
(c) => c.name === 'special_abilities'
|
||||||
|
);
|
||||||
|
expect(abilitiesCol?.type).toBe('JSON');
|
||||||
|
|
||||||
|
// Verify ENUM columns
|
||||||
|
const magicLevel = result.tables
|
||||||
|
.find((t) => t.name === 'realms')
|
||||||
|
?.columns.find((c) => c.name === 'magic_level');
|
||||||
|
expect(magicLevel?.type).toBe('ENUM');
|
||||||
|
|
||||||
|
// Verify indexes
|
||||||
|
const wizards = result.tables.find((t) => t.name === 'wizards');
|
||||||
|
expect(
|
||||||
|
wizards?.indexes.some((idx) => idx.name === 'idx_email')
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Verify many-to-many relationship table
|
||||||
|
const spellbooks = result.tables.find(
|
||||||
|
(t) => t.name === 'wizard_spellbooks'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
spellbooks?.columns.filter((c) => c.primaryKey)
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle the schema with skipValidation', async () => {
|
||||||
|
const result = await fromMySQLImproved(fantasyWorldSQL, {
|
||||||
|
skipValidation: true,
|
||||||
|
includeWarnings: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(17);
|
||||||
|
expect(result.relationships.length).toBeGreaterThan(20);
|
||||||
|
|
||||||
|
// Check for CASCADE actions
|
||||||
|
const cascadeRelations = result.relationships.filter(
|
||||||
|
(r) =>
|
||||||
|
r.deleteAction === 'CASCADE' || r.updateAction === 'CASCADE'
|
||||||
|
);
|
||||||
|
expect(cascadeRelations.length).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate the fantasy schema', () => {
|
||||||
|
const validation = validateMySQLSyntax(fantasyWorldSQL);
|
||||||
|
|
||||||
|
// Should be valid (no multi-line comment issues)
|
||||||
|
expect(validation.isValid).toBe(true);
|
||||||
|
expect(validation.errors).toHaveLength(0);
|
||||||
|
|
||||||
|
// May have some warnings but should be minimal
|
||||||
|
expect(validation.warnings.length).toBeLessThan(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fantasy Schema with Validation Issues', () => {
|
||||||
|
it('should handle SQL that becomes invalid after comment removal', async () => {
|
||||||
|
const problematicSQL = `
|
||||||
|
CREATE TABLE spell_components (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(100),
|
||||||
|
rarity VARCHAR(50), -- "Common",
|
||||||
|
"Rare", "Legendary" -- This will cause issues
|
||||||
|
properties JSON -- [
|
||||||
|
"magical_essence",
|
||||||
|
"dragon_scale"
|
||||||
|
] -- This JSON example will also cause issues
|
||||||
|
);`;
|
||||||
|
|
||||||
|
// After comment removal, this SQL becomes malformed
|
||||||
|
// The parser should handle this gracefully
|
||||||
|
try {
|
||||||
|
await fromMySQL(problematicSQL);
|
||||||
|
// If it parses, that's OK - the sanitizer may have cleaned it up
|
||||||
|
} catch (error) {
|
||||||
|
// If it fails, that's also OK - the SQL was problematic
|
||||||
|
expect(error.message).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect inline REFERENCES in fantasy schema', async () => {
|
||||||
|
const invalidSQL = `
|
||||||
|
CREATE TABLE wizard_familiars (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
wizard_id INT REFERENCES wizards(id), -- PostgreSQL style, not MySQL
|
||||||
|
familiar_name VARCHAR(100)
|
||||||
|
);`;
|
||||||
|
|
||||||
|
await expect(fromMySQL(invalidSQL)).rejects.toThrow(
|
||||||
|
'inline REFERENCES'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fromMySQL } from '../mysql';
|
||||||
|
|
||||||
|
describe('MySQL Fantasy Schema Test', () => {
|
||||||
|
it('should parse a complete fantasy realm management schema', async () => {
|
||||||
|
const fantasySQL = `
|
||||||
|
-- Enchanted Realms Database
|
||||||
|
CREATE TABLE magical_realms (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
realm_name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
magic_density DECIMAL(5,2) DEFAULT 100.00,
|
||||||
|
portal_coordinates VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_realm_name (realm_name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE creature_classes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
class_name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
base_health INT DEFAULT 100,
|
||||||
|
base_mana INT DEFAULT 50,
|
||||||
|
special_traits JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE magical_creatures (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
creature_name VARCHAR(200) NOT NULL,
|
||||||
|
class_id INT NOT NULL,
|
||||||
|
realm_id INT NOT NULL,
|
||||||
|
level INT DEFAULT 1,
|
||||||
|
experience_points INT DEFAULT 0,
|
||||||
|
is_legendary BOOLEAN DEFAULT FALSE,
|
||||||
|
abilities TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (class_id) REFERENCES creature_classes(id),
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES magical_realms(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_realm_creatures (realm_id),
|
||||||
|
INDEX idx_legendary (is_legendary)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE wizard_towers (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tower_name VARCHAR(100) NOT NULL,
|
||||||
|
realm_id INT NOT NULL,
|
||||||
|
height_meters INT DEFAULT 50,
|
||||||
|
defensive_wards JSON,
|
||||||
|
library_size ENUM('small', 'medium', 'large', 'grand') DEFAULT 'medium',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES magical_realms(id),
|
||||||
|
INDEX idx_realm_towers (realm_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE arcane_wizards (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
wizard_name VARCHAR(200) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
tower_id INT,
|
||||||
|
specialization VARCHAR(50) NOT NULL,
|
||||||
|
mana_capacity INT DEFAULT 1000,
|
||||||
|
spell_slots INT DEFAULT 10,
|
||||||
|
familiar_creature_id INT,
|
||||||
|
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (tower_id) REFERENCES wizard_towers(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (familiar_creature_id) REFERENCES magical_creatures(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_email (email),
|
||||||
|
INDEX idx_tower (tower_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE spell_tomes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
spell_name VARCHAR(200) NOT NULL,
|
||||||
|
mana_cost INT DEFAULT 50,
|
||||||
|
cast_time_seconds DECIMAL(4,2) DEFAULT 1.5,
|
||||||
|
damage_type ENUM('fire', 'ice', 'lightning', 'arcane', 'nature', 'shadow') NOT NULL,
|
||||||
|
spell_description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FULLTEXT idx_spell_search (spell_name, spell_description)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE wizard_spellbooks (
|
||||||
|
wizard_id INT NOT NULL,
|
||||||
|
spell_id INT NOT NULL,
|
||||||
|
mastery_level INT DEFAULT 1,
|
||||||
|
times_cast INT DEFAULT 0,
|
||||||
|
learned_date DATE,
|
||||||
|
PRIMARY KEY (wizard_id, spell_id),
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES arcane_wizards(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (spell_id) REFERENCES spell_tomes(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE enchanted_artifacts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
artifact_name VARCHAR(200) UNIQUE NOT NULL,
|
||||||
|
power_level INT CHECK (power_level BETWEEN 1 AND 100),
|
||||||
|
curse_type VARCHAR(100),
|
||||||
|
owner_wizard_id INT,
|
||||||
|
found_in_realm_id INT,
|
||||||
|
enchantments JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_wizard_id) REFERENCES arcane_wizards(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (found_in_realm_id) REFERENCES magical_realms(id),
|
||||||
|
INDEX idx_power (power_level),
|
||||||
|
INDEX idx_owner (owner_wizard_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE portal_network (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
portal_name VARCHAR(100) NOT NULL,
|
||||||
|
source_realm_id INT NOT NULL,
|
||||||
|
destination_realm_id INT NOT NULL,
|
||||||
|
stability_percentage DECIMAL(5,2) DEFAULT 95.00,
|
||||||
|
mana_cost_per_use INT DEFAULT 100,
|
||||||
|
is_bidirectional BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (source_realm_id) REFERENCES magical_realms(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (destination_realm_id) REFERENCES magical_realms(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uk_portal_connection (source_realm_id, destination_realm_id),
|
||||||
|
INDEX idx_source (source_realm_id),
|
||||||
|
INDEX idx_destination (destination_realm_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE magical_guilds (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
guild_name VARCHAR(200) UNIQUE NOT NULL,
|
||||||
|
founding_wizard_id INT,
|
||||||
|
headquarters_tower_id INT,
|
||||||
|
guild_treasury INT DEFAULT 0,
|
||||||
|
member_limit INT DEFAULT 50,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (founding_wizard_id) REFERENCES arcane_wizards(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (headquarters_tower_id) REFERENCES wizard_towers(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE guild_memberships (
|
||||||
|
wizard_id INT NOT NULL,
|
||||||
|
guild_id INT NOT NULL,
|
||||||
|
joined_date DATE NOT NULL,
|
||||||
|
guild_rank ENUM('apprentice', 'member', 'elder', 'master') DEFAULT 'apprentice',
|
||||||
|
contribution_points INT DEFAULT 0,
|
||||||
|
PRIMARY KEY (wizard_id, guild_id),
|
||||||
|
FOREIGN KEY (wizard_id) REFERENCES arcane_wizards(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES magical_guilds(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE realm_events (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
realm_id INT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
magic_fluctuation INT DEFAULT 0,
|
||||||
|
participants JSON,
|
||||||
|
event_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES magical_realms(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_realm_time (realm_id, event_timestamp),
|
||||||
|
INDEX idx_event_type (event_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Additional constraints via ALTER TABLE
|
||||||
|
ALTER TABLE magical_creatures
|
||||||
|
ADD CONSTRAINT chk_level CHECK (level BETWEEN 1 AND 100);
|
||||||
|
|
||||||
|
ALTER TABLE wizard_spellbooks
|
||||||
|
ADD CONSTRAINT chk_mastery CHECK (mastery_level BETWEEN 1 AND 10);
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Parsing fantasy realm schema...');
|
||||||
|
const result = await fromMySQL(fantasySQL);
|
||||||
|
|
||||||
|
// Expected structure
|
||||||
|
const expectedTables = [
|
||||||
|
'arcane_wizards',
|
||||||
|
'creature_classes',
|
||||||
|
'enchanted_artifacts',
|
||||||
|
'guild_memberships',
|
||||||
|
'magical_creatures',
|
||||||
|
'magical_guilds',
|
||||||
|
'magical_realms',
|
||||||
|
'portal_network',
|
||||||
|
'realm_events',
|
||||||
|
'spell_tomes',
|
||||||
|
'wizard_spellbooks',
|
||||||
|
'wizard_towers',
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Found tables:', result.tables.map((t) => t.name).sort());
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(12);
|
||||||
|
expect(result.tables.map((t) => t.name).sort()).toEqual(expectedTables);
|
||||||
|
|
||||||
|
// Verify relationships
|
||||||
|
console.log(
|
||||||
|
`\nTotal relationships found: ${result.relationships.length}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check some key relationships
|
||||||
|
const creatureRelations = result.relationships.filter(
|
||||||
|
(r) => r.sourceTable === 'magical_creatures'
|
||||||
|
);
|
||||||
|
expect(creatureRelations).toHaveLength(2); // class_id and realm_id
|
||||||
|
|
||||||
|
const wizardRelations = result.relationships.filter(
|
||||||
|
(r) => r.sourceTable === 'arcane_wizards'
|
||||||
|
);
|
||||||
|
expect(wizardRelations).toHaveLength(2); // tower_id and familiar_creature_id
|
||||||
|
|
||||||
|
// Check CASCADE relationships
|
||||||
|
const cascadeRelations = result.relationships.filter(
|
||||||
|
(r) => r.deleteAction === 'CASCADE'
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`\nRelationships with CASCADE delete: ${cascadeRelations.length}`
|
||||||
|
);
|
||||||
|
expect(cascadeRelations.length).toBeGreaterThan(5);
|
||||||
|
|
||||||
|
// Verify special columns
|
||||||
|
const realms = result.tables.find((t) => t.name === 'magical_realms');
|
||||||
|
const magicDensity = realms?.columns.find(
|
||||||
|
(c) => c.name === 'magic_density'
|
||||||
|
);
|
||||||
|
expect(magicDensity?.type).toBe('DECIMAL');
|
||||||
|
|
||||||
|
const spells = result.tables.find((t) => t.name === 'spell_tomes');
|
||||||
|
const damageType = spells?.columns.find(
|
||||||
|
(c) => c.name === 'damage_type'
|
||||||
|
);
|
||||||
|
expect(damageType?.type).toBe('ENUM');
|
||||||
|
|
||||||
|
// Check indexes
|
||||||
|
const wizards = result.tables.find((t) => t.name === 'arcane_wizards');
|
||||||
|
expect(wizards?.indexes.some((idx) => idx.name === 'idx_email')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check unique constraints
|
||||||
|
const portals = result.tables.find((t) => t.name === 'portal_network');
|
||||||
|
expect(
|
||||||
|
portals?.indexes.some(
|
||||||
|
(idx) =>
|
||||||
|
idx.name === 'uk_portal_connection' && idx.unique === true
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
console.log('\n=== Parsing Summary ===');
|
||||||
|
console.log(`Tables parsed: ${result.tables.length}`);
|
||||||
|
console.log(`Relationships found: ${result.relationships.length}`);
|
||||||
|
console.log(
|
||||||
|
`Tables with indexes: ${result.tables.filter((t) => t.indexes.length > 0).length}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Tables with primary keys: ${
|
||||||
|
result.tables.filter((t) => t.columns.some((c) => c.primaryKey))
|
||||||
|
.length
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fromMySQL } from '../mysql';
|
||||||
|
import { fromMySQLImproved } from '../mysql-improved';
|
||||||
|
|
||||||
|
describe('MySQL Final Integration', () => {
|
||||||
|
it('should use the improved parser from fromMySQL', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(100)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
user_id INT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = await fromMySQL(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(2);
|
||||||
|
expect(result.relationships).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject inline REFERENCES', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id)
|
||||||
|
);`;
|
||||||
|
|
||||||
|
await expect(fromMySQL(sql)).rejects.toThrow(
|
||||||
|
'MySQL/MariaDB does not support inline REFERENCES'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a large fantasy schema with skipValidation', async () => {
|
||||||
|
const fantasySQL = `
|
||||||
|
-- Dragon Registry System
|
||||||
|
CREATE TABLE dragon_species (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
species_name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
element_affinity ENUM('fire', 'ice', 'lightning', 'earth', 'shadow', 'light') NOT NULL,
|
||||||
|
average_wingspan_meters DECIMAL(6,2),
|
||||||
|
is_ancient BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE dragon_lairs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
location_name VARCHAR(200) NOT NULL,
|
||||||
|
coordinates JSON, -- {"x": 1000, "y": 2000, "z": 500}
|
||||||
|
treasure_value INT DEFAULT 0,
|
||||||
|
trap_level INT DEFAULT 1 CHECK (trap_level BETWEEN 1 AND 10),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_treasure (treasure_value)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE dragons (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
dragon_name VARCHAR(200) NOT NULL,
|
||||||
|
species_id INT NOT NULL,
|
||||||
|
lair_id INT,
|
||||||
|
age_years INT DEFAULT 0,
|
||||||
|
hoard_size INT DEFAULT 0,
|
||||||
|
breath_weapon_power INT DEFAULT 100,
|
||||||
|
is_sleeping BOOLEAN DEFAULT FALSE,
|
||||||
|
last_seen_at DATETIME,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (species_id) REFERENCES dragon_species(id),
|
||||||
|
FOREIGN KEY (lair_id) REFERENCES dragon_lairs(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_species (species_id),
|
||||||
|
INDEX idx_lair (lair_id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- Adventurer's Guild
|
||||||
|
CREATE TABLE adventurer_classes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
class_name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
primary_stat ENUM('strength', 'dexterity', 'intelligence', 'wisdom', 'charisma') NOT NULL,
|
||||||
|
hit_dice INT DEFAULT 6,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE adventurers (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
adventurer_name VARCHAR(200) NOT NULL,
|
||||||
|
class_id INT NOT NULL,
|
||||||
|
level INT DEFAULT 1,
|
||||||
|
experience_points INT DEFAULT 0,
|
||||||
|
gold_pieces INT DEFAULT 100,
|
||||||
|
is_alive BOOLEAN DEFAULT TRUE,
|
||||||
|
last_quest_date DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (class_id) REFERENCES adventurer_classes(id),
|
||||||
|
INDEX idx_class (class_id),
|
||||||
|
INDEX idx_level (level)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE dragon_encounters (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
dragon_id INT NOT NULL,
|
||||||
|
adventurer_id INT NOT NULL,
|
||||||
|
encounter_date DATETIME NOT NULL,
|
||||||
|
outcome ENUM('fled', 'negotiated', 'fought', 'befriended') NOT NULL,
|
||||||
|
gold_stolen INT DEFAULT 0,
|
||||||
|
survived BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (dragon_id) REFERENCES dragons(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (adventurer_id) REFERENCES adventurers(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_dragon (dragon_id),
|
||||||
|
INDEX idx_adventurer (adventurer_id),
|
||||||
|
INDEX idx_date (encounter_date)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// First, let's try with skipValidation
|
||||||
|
const result = await fromMySQLImproved(fantasySQL, {
|
||||||
|
skipValidation: true,
|
||||||
|
includeWarnings: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n=== Results with skipValidation ===');
|
||||||
|
console.log('Tables:', result.tables.length);
|
||||||
|
console.log('Relationships:', result.relationships.length);
|
||||||
|
console.log('Warnings:', result.warnings?.length || 0);
|
||||||
|
|
||||||
|
expect(result.tables.length).toBe(6);
|
||||||
|
expect(result.relationships.length).toBeGreaterThanOrEqual(5);
|
||||||
|
|
||||||
|
// Verify key tables
|
||||||
|
const dragons = result.tables.find((t) => t.name === 'dragons');
|
||||||
|
expect(dragons).toBeDefined();
|
||||||
|
expect(
|
||||||
|
dragons?.columns.find((c) => c.name === 'breath_weapon_power')
|
||||||
|
).toBeDefined();
|
||||||
|
|
||||||
|
// Check relationships
|
||||||
|
const dragonRelations = result.relationships.filter(
|
||||||
|
(r) => r.sourceTable === 'dragons'
|
||||||
|
);
|
||||||
|
expect(dragonRelations).toHaveLength(2); // species_id and lair_id
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SQL with comments like PostgreSQL', async () => {
|
||||||
|
// Use properly formatted SQL that won't break when comments are removed
|
||||||
|
const sqlWithComments = `
|
||||||
|
CREATE TABLE test (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
data JSON, -- Example: ["value1", "value2"]
|
||||||
|
status VARCHAR(50), -- Can be "active" or "inactive"
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- Auto-set timestamp
|
||||||
|
);`;
|
||||||
|
|
||||||
|
// This should work because comments are removed first
|
||||||
|
const result = await fromMySQL(sqlWithComments);
|
||||||
|
|
||||||
|
console.log('\n=== Result ===');
|
||||||
|
console.log('Tables:', result.tables.length);
|
||||||
|
console.log('Columns:', result.tables[0]?.columns.length);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(1);
|
||||||
|
expect(result.tables[0].name).toBe('test');
|
||||||
|
expect(result.tables[0].columns).toHaveLength(4);
|
||||||
|
|
||||||
|
// Verify columns were parsed correctly
|
||||||
|
const columns = result.tables[0].columns.map((c) => c.name);
|
||||||
|
expect(columns).toContain('id');
|
||||||
|
expect(columns).toContain('data');
|
||||||
|
expect(columns).toContain('status');
|
||||||
|
expect(columns).toContain('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SQL that may become problematic after comment removal', async () => {
|
||||||
|
// This SQL is problematic because removing comments leaves invalid syntax
|
||||||
|
const problematicSql = `
|
||||||
|
CREATE TABLE test (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
data JSON, -- [
|
||||||
|
"value1",
|
||||||
|
"value2"
|
||||||
|
] -- This leaves broken syntax
|
||||||
|
);`;
|
||||||
|
|
||||||
|
// The parser might handle this in different ways
|
||||||
|
try {
|
||||||
|
const result = await fromMySQL(problematicSql);
|
||||||
|
// If it succeeds, it might have parsed partially
|
||||||
|
expect(result.tables.length).toBeGreaterThanOrEqual(0);
|
||||||
|
} catch (error) {
|
||||||
|
// If it fails, that's also acceptable
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fromMySQLImproved } from '../mysql-improved';
|
||||||
|
|
||||||
|
describe('MySQL Fix Test', () => {
|
||||||
|
it('should parse foreign keys with comments containing commas', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE product_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE packages (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
badge_text VARCHAR(50), -- "Beliebt", "Empfohlen", etc.
|
||||||
|
color_code VARCHAR(7), -- Hex-Farbe für UI
|
||||||
|
FOREIGN KEY (category_id) REFERENCES product_categories(id)
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql, { skipValidation: true });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'Tables:',
|
||||||
|
result.tables.map((t) => t.name)
|
||||||
|
);
|
||||||
|
console.log('Relationships:', result.relationships.length);
|
||||||
|
result.relationships.forEach((r) => {
|
||||||
|
console.log(
|
||||||
|
` ${r.sourceTable}.${r.sourceColumn} -> ${r.targetTable}.${r.targetColumn}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(2);
|
||||||
|
expect(result.relationships).toHaveLength(1);
|
||||||
|
expect(result.relationships[0]).toMatchObject({
|
||||||
|
sourceTable: 'packages',
|
||||||
|
sourceColumn: 'category_id',
|
||||||
|
targetTable: 'product_categories',
|
||||||
|
targetColumn: 'id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse the actual packages table from the file', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE product_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon VARCHAR(255),
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pakete (für VServer, Game-Server, Web-Hosting)
|
||||||
|
CREATE TABLE packages (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Paket-Eigenschaften
|
||||||
|
is_popular BOOLEAN DEFAULT FALSE,
|
||||||
|
badge_text VARCHAR(50), -- Examples: "Beliebt", "Empfohlen", etc.
|
||||||
|
color_code VARCHAR(7), -- Hex color for UI
|
||||||
|
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
FOREIGN KEY (category_id) REFERENCES product_categories(id),
|
||||||
|
|
||||||
|
-- Only categories with packages: VServer(2), Game-Server(1), Web-Hosting(4)
|
||||||
|
CHECK (category_id IN (1, 2, 4))
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql, { skipValidation: true });
|
||||||
|
|
||||||
|
console.log('\nActual packages table test:');
|
||||||
|
console.log(
|
||||||
|
'Tables:',
|
||||||
|
result.tables.map((t) => t.name)
|
||||||
|
);
|
||||||
|
console.log('Relationships:', result.relationships.length);
|
||||||
|
result.relationships.forEach((r) => {
|
||||||
|
console.log(
|
||||||
|
` ${r.sourceTable}.${r.sourceColumn} -> ${r.targetTable}.${r.targetColumn}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const packagesRelationships = result.relationships.filter(
|
||||||
|
(r) => r.sourceTable === 'packages'
|
||||||
|
);
|
||||||
|
expect(packagesRelationships).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fromMySQLImproved } from '../mysql-improved';
|
||||||
|
|
||||||
|
describe('MySQL Integration Tests', () => {
|
||||||
|
describe('E-Commerce Database Schema', () => {
|
||||||
|
it('should parse a complete e-commerce database', async () => {
|
||||||
|
const sql = `
|
||||||
|
-- E-commerce database schema
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
parent_id INT,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_parent (parent_id),
|
||||||
|
INDEX idx_active (is_active)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE brands (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
logo_url VARCHAR(500),
|
||||||
|
website VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
sku VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
brand_id INT,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
price DECIMAL(10,2) NOT NULL,
|
||||||
|
compare_at_price DECIMAL(10,2),
|
||||||
|
cost DECIMAL(10,2),
|
||||||
|
quantity INT DEFAULT 0,
|
||||||
|
weight DECIMAL(8,3),
|
||||||
|
status ENUM('active', 'draft', 'archived') DEFAULT 'draft',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (brand_id) REFERENCES brands(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id),
|
||||||
|
INDEX idx_sku (sku),
|
||||||
|
INDEX idx_category (category_id),
|
||||||
|
INDEX idx_brand (brand_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
FULLTEXT idx_search (name, description)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE product_images (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
image_url VARCHAR(500) NOT NULL,
|
||||||
|
alt_text VARCHAR(255),
|
||||||
|
position INT DEFAULT 0,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_product (product_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
phone VARCHAR(20),
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_email (email)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE addresses (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
customer_id INT NOT NULL,
|
||||||
|
type ENUM('billing', 'shipping', 'both') DEFAULT 'both',
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
company VARCHAR(100),
|
||||||
|
address_line1 VARCHAR(255) NOT NULL,
|
||||||
|
address_line2 VARCHAR(255),
|
||||||
|
city VARCHAR(100) NOT NULL,
|
||||||
|
state_province VARCHAR(100),
|
||||||
|
postal_code VARCHAR(20),
|
||||||
|
country_code CHAR(2) NOT NULL,
|
||||||
|
phone VARCHAR(20),
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_customer (customer_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE carts (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
customer_id INT,
|
||||||
|
session_id VARCHAR(128),
|
||||||
|
status ENUM('active', 'abandoned', 'converted') DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_customer (customer_id),
|
||||||
|
INDEX idx_session (session_id),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE cart_items (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
cart_id INT NOT NULL,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
quantity INT NOT NULL DEFAULT 1,
|
||||||
|
price DECIMAL(10,2) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id),
|
||||||
|
UNIQUE KEY uk_cart_product (cart_id, product_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_number VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
customer_id INT NOT NULL,
|
||||||
|
billing_address_id INT NOT NULL,
|
||||||
|
shipping_address_id INT NOT NULL,
|
||||||
|
status ENUM('pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded') DEFAULT 'pending',
|
||||||
|
subtotal DECIMAL(10,2) NOT NULL,
|
||||||
|
tax_amount DECIMAL(10,2) DEFAULT 0.00,
|
||||||
|
shipping_amount DECIMAL(10,2) DEFAULT 0.00,
|
||||||
|
discount_amount DECIMAL(10,2) DEFAULT 0.00,
|
||||||
|
total_amount DECIMAL(10,2) NOT NULL,
|
||||||
|
currency_code CHAR(3) DEFAULT 'USD',
|
||||||
|
payment_status ENUM('pending', 'paid', 'partially_paid', 'refunded', 'failed') DEFAULT 'pending',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id),
|
||||||
|
FOREIGN KEY (billing_address_id) REFERENCES addresses(id),
|
||||||
|
FOREIGN KEY (shipping_address_id) REFERENCES addresses(id),
|
||||||
|
INDEX idx_order_number (order_number),
|
||||||
|
INDEX idx_customer (customer_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE order_items (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_id INT NOT NULL,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
product_name VARCHAR(255) NOT NULL,
|
||||||
|
product_sku VARCHAR(50) NOT NULL,
|
||||||
|
quantity INT NOT NULL,
|
||||||
|
price DECIMAL(10,2) NOT NULL,
|
||||||
|
total DECIMAL(10,2) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id),
|
||||||
|
INDEX idx_order (order_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE payments (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_id INT NOT NULL,
|
||||||
|
payment_method ENUM('credit_card', 'debit_card', 'paypal', 'stripe', 'bank_transfer') NOT NULL,
|
||||||
|
transaction_id VARCHAR(255) UNIQUE,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
currency_code CHAR(3) DEFAULT 'USD',
|
||||||
|
status ENUM('pending', 'processing', 'completed', 'failed', 'refunded') DEFAULT 'pending',
|
||||||
|
gateway_response JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (order_id) REFERENCES orders(id),
|
||||||
|
INDEX idx_order (order_id),
|
||||||
|
INDEX idx_transaction (transaction_id),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE reviews (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
customer_id INT NOT NULL,
|
||||||
|
order_id INT,
|
||||||
|
rating INT NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||||
|
title VARCHAR(255),
|
||||||
|
comment TEXT,
|
||||||
|
is_verified_purchase BOOLEAN DEFAULT FALSE,
|
||||||
|
is_featured BOOLEAN DEFAULT FALSE,
|
||||||
|
helpful_count INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE SET NULL,
|
||||||
|
UNIQUE KEY uk_product_customer (product_id, customer_id),
|
||||||
|
INDEX idx_product (product_id),
|
||||||
|
INDEX idx_rating (rating),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE coupons (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
code VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
discount_type ENUM('fixed', 'percentage') NOT NULL,
|
||||||
|
discount_amount DECIMAL(10,2) NOT NULL,
|
||||||
|
minimum_amount DECIMAL(10,2),
|
||||||
|
usage_limit INT,
|
||||||
|
usage_count INT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
valid_from DATETIME NOT NULL,
|
||||||
|
valid_until DATETIME,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_code (code),
|
||||||
|
INDEX idx_active (is_active),
|
||||||
|
INDEX idx_valid_dates (valid_from, valid_until)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE order_coupons (
|
||||||
|
order_id INT NOT NULL,
|
||||||
|
coupon_id INT NOT NULL,
|
||||||
|
discount_amount DECIMAL(10,2) NOT NULL,
|
||||||
|
PRIMARY KEY (order_id, coupon_id),
|
||||||
|
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (coupon_id) REFERENCES coupons(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
// Verify all tables are parsed
|
||||||
|
expect(result.tables).toHaveLength(14);
|
||||||
|
|
||||||
|
const expectedTables = [
|
||||||
|
'addresses',
|
||||||
|
'brands',
|
||||||
|
'cart_items',
|
||||||
|
'carts',
|
||||||
|
'categories',
|
||||||
|
'coupons',
|
||||||
|
'customers',
|
||||||
|
'order_coupons',
|
||||||
|
'order_items',
|
||||||
|
'orders',
|
||||||
|
'payments',
|
||||||
|
'product_images',
|
||||||
|
'products',
|
||||||
|
'reviews',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(result.tables.map((t) => t.name).sort()).toEqual(
|
||||||
|
expectedTables
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify key relationships
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'products' &&
|
||||||
|
r.targetTable === 'categories'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'cart_items' &&
|
||||||
|
r.targetTable === 'products'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'orders' &&
|
||||||
|
r.targetTable === 'customers'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Check self-referencing relationship
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'categories' &&
|
||||||
|
r.targetTable === 'categories' &&
|
||||||
|
r.sourceColumn === 'parent_id'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Verify ENUMs are parsed
|
||||||
|
const products = result.tables.find((t) => t.name === 'products');
|
||||||
|
const statusColumn = products?.columns.find(
|
||||||
|
(c) => c.name === 'status'
|
||||||
|
);
|
||||||
|
expect(statusColumn?.type).toBe('ENUM');
|
||||||
|
|
||||||
|
// Verify indexes
|
||||||
|
expect(
|
||||||
|
products?.indexes.some((idx) => idx.name === 'idx_sku')
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Social Media Platform Schema', () => {
|
||||||
|
it('should parse a social media database with complex relationships', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
bio TEXT,
|
||||||
|
avatar_url VARCHAR(500),
|
||||||
|
cover_image_url VARCHAR(500),
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
is_private BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_username (username),
|
||||||
|
INDEX idx_email (email)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
visibility ENUM('public', 'followers', 'private') DEFAULT 'public',
|
||||||
|
reply_to_id BIGINT,
|
||||||
|
repost_of_id BIGINT,
|
||||||
|
like_count INT DEFAULT 0,
|
||||||
|
reply_count INT DEFAULT 0,
|
||||||
|
repost_count INT DEFAULT 0,
|
||||||
|
view_count INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (reply_to_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (repost_of_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_created (created_at),
|
||||||
|
FULLTEXT idx_content (content)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE follows (
|
||||||
|
follower_id BIGINT NOT NULL,
|
||||||
|
following_id BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (follower_id, following_id),
|
||||||
|
FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (following_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_following (following_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE likes (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
post_id BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, post_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_post (post_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE hashtags (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
tag VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
post_count INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_tag (tag)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE post_hashtags (
|
||||||
|
post_id BIGINT NOT NULL,
|
||||||
|
hashtag_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (post_id, hashtag_id),
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (hashtag_id) REFERENCES hashtags(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_hashtag (hashtag_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
sender_id BIGINT NOT NULL,
|
||||||
|
recipient_id BIGINT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_recipient (recipient_id, is_read),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
type ENUM('like', 'follow', 'reply', 'repost', 'mention') NOT NULL,
|
||||||
|
actor_id BIGINT NOT NULL,
|
||||||
|
post_id BIGINT,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_user_unread (user_id, is_read),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(8);
|
||||||
|
|
||||||
|
// Check self-referencing relationships in posts
|
||||||
|
const postRelationships = result.relationships.filter(
|
||||||
|
(r) => r.sourceTable === 'posts'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
postRelationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.targetTable === 'posts' &&
|
||||||
|
r.sourceColumn === 'reply_to_id'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
postRelationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.targetTable === 'posts' &&
|
||||||
|
r.sourceColumn === 'repost_of_id'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Check many-to-many relationships
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'follows' &&
|
||||||
|
r.sourceColumn === 'follower_id' &&
|
||||||
|
r.targetTable === 'users'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.relationships.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceTable === 'follows' &&
|
||||||
|
r.sourceColumn === 'following_id' &&
|
||||||
|
r.targetTable === 'users'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Verify composite primary keys
|
||||||
|
const follows = result.tables.find((t) => t.name === 'follows');
|
||||||
|
const followerCol = follows?.columns.find(
|
||||||
|
(c) => c.name === 'follower_id'
|
||||||
|
);
|
||||||
|
const followingCol = follows?.columns.find(
|
||||||
|
(c) => c.name === 'following_id'
|
||||||
|
);
|
||||||
|
expect(followerCol?.primaryKey).toBe(true);
|
||||||
|
expect(followingCol?.primaryKey).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Financial System Schema', () => {
|
||||||
|
it('should parse a financial system with decimal precision and constraints', async () => {
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE currencies (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
code CHAR(3) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
symbol VARCHAR(5),
|
||||||
|
decimal_places TINYINT DEFAULT 2,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
account_number VARCHAR(20) UNIQUE NOT NULL,
|
||||||
|
account_type ENUM('checking', 'savings', 'investment', 'credit') NOT NULL,
|
||||||
|
currency_id INT NOT NULL,
|
||||||
|
balance DECIMAL(19,4) DEFAULT 0.0000,
|
||||||
|
available_balance DECIMAL(19,4) DEFAULT 0.0000,
|
||||||
|
credit_limit DECIMAL(19,4),
|
||||||
|
interest_rate DECIMAL(5,4),
|
||||||
|
status ENUM('active', 'frozen', 'closed') DEFAULT 'active',
|
||||||
|
opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
closed_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (currency_id) REFERENCES currencies(id),
|
||||||
|
INDEX idx_account_number (account_number),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
CHECK (balance >= 0 OR account_type = 'credit'),
|
||||||
|
CHECK (available_balance <= balance OR account_type = 'credit')
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
transaction_ref VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
from_account_id BIGINT,
|
||||||
|
to_account_id BIGINT,
|
||||||
|
amount DECIMAL(19,4) NOT NULL,
|
||||||
|
currency_id INT NOT NULL,
|
||||||
|
type ENUM('deposit', 'withdrawal', 'transfer', 'fee', 'interest') NOT NULL,
|
||||||
|
status ENUM('pending', 'processing', 'completed', 'failed', 'reversed') DEFAULT 'pending',
|
||||||
|
description TEXT,
|
||||||
|
metadata JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
processed_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (from_account_id) REFERENCES accounts(id),
|
||||||
|
FOREIGN KEY (to_account_id) REFERENCES accounts(id),
|
||||||
|
FOREIGN KEY (currency_id) REFERENCES currencies(id),
|
||||||
|
INDEX idx_ref (transaction_ref),
|
||||||
|
INDEX idx_from_account (from_account_id),
|
||||||
|
INDEX idx_to_account (to_account_id),
|
||||||
|
INDEX idx_created (created_at),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
CHECK (from_account_id IS NOT NULL OR to_account_id IS NOT NULL)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE exchange_rates (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
from_currency_id INT NOT NULL,
|
||||||
|
to_currency_id INT NOT NULL,
|
||||||
|
rate DECIMAL(19,10) NOT NULL,
|
||||||
|
effective_date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (from_currency_id) REFERENCES currencies(id),
|
||||||
|
FOREIGN KEY (to_currency_id) REFERENCES currencies(id),
|
||||||
|
UNIQUE KEY uk_currency_pair_date (from_currency_id, to_currency_id, effective_date),
|
||||||
|
INDEX idx_effective_date (effective_date)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id BIGINT NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
user_id BIGINT,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent TEXT,
|
||||||
|
old_values JSON,
|
||||||
|
new_values JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_entity (entity_type, entity_id),
|
||||||
|
INDEX idx_created (created_at),
|
||||||
|
INDEX idx_action (action)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await fromMySQLImproved(sql);
|
||||||
|
|
||||||
|
expect(result.tables).toHaveLength(5);
|
||||||
|
|
||||||
|
// Check decimal precision is preserved
|
||||||
|
const accounts = result.tables.find((t) => t.name === 'accounts');
|
||||||
|
const balanceCol = accounts?.columns.find(
|
||||||
|
(c) => c.name === 'balance'
|
||||||
|
);
|
||||||
|
expect(balanceCol?.type).toBe('DECIMAL');
|
||||||
|
|
||||||
|
const transactionFKs = result.relationships.filter(
|
||||||
|
(r) => r.sourceTable === 'transactions'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
transactionFKs.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceColumn === 'from_account_id' &&
|
||||||
|
r.targetTable === 'accounts'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
transactionFKs.some(
|
||||||
|
(r) =>
|
||||||
|
r.sourceColumn === 'to_account_id' &&
|
||||||
|
r.targetTable === 'accounts'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Check composite unique constraint
|
||||||
|
const exchangeRates = result.tables.find(
|
||||||
|
(t) => t.name === 'exchange_rates'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
exchangeRates?.indexes.some(
|
||||||
|
(idx) =>
|
||||||
|
idx.name === 'uk_currency_pair_date' &&
|
||||||
|
idx.unique === true
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
validateMySQLSyntax,
|
||||||
|
formatValidationMessage,
|
||||||
|
} from '../mysql-validator';
|
||||||
|
|
||||||
|
describe('MySQL Validator', () => {
|
||||||
|
it('should pass valid MySQL after comments are removed', () => {
|
||||||
|
// In the new flow, comments are removed before validation
|
||||||
|
// So this SQL would have comments stripped and be valid
|
||||||
|
const sql = `CREATE TABLE packages (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
badge_text VARCHAR(50),
|
||||||
|
color_code VARCHAR(7)
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = validateMySQLSyntax(sql);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate clean SQL without comments', () => {
|
||||||
|
// Comments would be removed before validation
|
||||||
|
const sql = `CREATE TABLE product_vserver (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
available_os JSON
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = validateMySQLSyntax(sql);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect inline REFERENCES', () => {
|
||||||
|
const sql = `CREATE TABLE users (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
profile_id INT REFERENCES profiles(id)
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = validateMySQLSyntax(sql);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0].code).toBe('INLINE_REFERENCES');
|
||||||
|
expect(result.errors[0].line).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass valid MySQL', () => {
|
||||||
|
const sql = `CREATE TABLE users (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
title VARCHAR(200),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = validateMySQLSyntax(sql);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate a fantasy-themed MySQL schema', () => {
|
||||||
|
// Test with already sanitized SQL (comments removed)
|
||||||
|
const sql = `
|
||||||
|
CREATE TABLE magic_schools (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
element_type VARCHAR(50),
|
||||||
|
forbidden_spells JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE wizards (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
magic_school_id INT REFERENCES magic_schools(id), -- Inline REFERENCES (PostgreSQL style)
|
||||||
|
power_level INT DEFAULT 1
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = validateMySQLSyntax(sql);
|
||||||
|
|
||||||
|
console.log('\n=== Fantasy Schema Validation ===');
|
||||||
|
console.log(`Valid: ${result.isValid}`);
|
||||||
|
console.log(`Errors: ${result.errors.length}`);
|
||||||
|
console.log(`Warnings: ${result.warnings.length}`);
|
||||||
|
|
||||||
|
// Should only have inline REFERENCES error now
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors.length).toBe(1);
|
||||||
|
expect(result.errors[0].code).toBe('INLINE_REFERENCES');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format validation messages nicely', () => {
|
||||||
|
const sql = `CREATE TABLE test (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
ref_id INT REFERENCES other(id)
|
||||||
|
);`;
|
||||||
|
|
||||||
|
const result = validateMySQLSyntax(sql);
|
||||||
|
const message = formatValidationMessage(result);
|
||||||
|
|
||||||
|
console.log('\nFormatted validation message:');
|
||||||
|
console.log(message);
|
||||||
|
|
||||||
|
expect(message).toContain('❌ MySQL/MariaDB syntax validation failed');
|
||||||
|
expect(message).toContain('Error at line 3');
|
||||||
|
expect(message).toContain('💡 Suggestion');
|
||||||
|
});
|
||||||
|
});
|
||||||
1165
src/lib/data/sql-import/dialect-importers/mysql/mysql-improved.ts
Normal file
1165
src/lib/data/sql-import/dialect-importers/mysql/mysql-improved.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
|||||||
|
export interface MySQLValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: MySQLValidationError[];
|
||||||
|
warnings: MySQLValidationWarning[];
|
||||||
|
canAutoFix: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MySQLValidationError {
|
||||||
|
line?: number;
|
||||||
|
column?: number;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MySQLValidationWarning {
|
||||||
|
line?: number;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMySQLSyntax(sql: string): MySQLValidationResult {
|
||||||
|
const errors: MySQLValidationError[] = [];
|
||||||
|
const warnings: MySQLValidationWarning[] = [];
|
||||||
|
const canAutoFix = false;
|
||||||
|
|
||||||
|
const lines = sql.split('\n');
|
||||||
|
|
||||||
|
// Check for common MySQL syntax issues
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const lineNum = i + 1;
|
||||||
|
|
||||||
|
// Skip comment checks if comments are already removed
|
||||||
|
// This check is now less relevant since sanitizeSql removes comments first
|
||||||
|
|
||||||
|
// 2. Check for inline REFERENCES (PostgreSQL style)
|
||||||
|
if (/\w+\s+\w+\s+(?:PRIMARY\s+KEY\s+)?REFERENCES\s+/i.test(line)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'MySQL/MariaDB does not support inline REFERENCES in column definitions.',
|
||||||
|
code: 'INLINE_REFERENCES',
|
||||||
|
suggestion:
|
||||||
|
'Use FOREIGN KEY constraint instead:\nFOREIGN KEY (column_name) REFERENCES table_name(column_name)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check for missing semicolons - be more selective
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
// Only check if this looks like the end of a CREATE TABLE statement
|
||||||
|
if (
|
||||||
|
trimmedLine &&
|
||||||
|
trimmedLine.endsWith(')') &&
|
||||||
|
!trimmedLine.endsWith(';') &&
|
||||||
|
!trimmedLine.endsWith(',') &&
|
||||||
|
i + 1 < lines.length
|
||||||
|
) {
|
||||||
|
// Look backwards to see if this is part of a CREATE TABLE
|
||||||
|
let isCreateTable = false;
|
||||||
|
for (let j = i; j >= Math.max(0, i - 20); j--) {
|
||||||
|
if (/CREATE\s+TABLE/i.test(lines[j])) {
|
||||||
|
isCreateTable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreateTable) {
|
||||||
|
const nextLine = lines[i + 1].trim();
|
||||||
|
// Only warn if next line starts a new statement
|
||||||
|
if (
|
||||||
|
nextLine &&
|
||||||
|
nextLine.match(
|
||||||
|
/^(CREATE|DROP|ALTER|INSERT|UPDATE|DELETE)\s+/i
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
warnings.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: 'Statement may be missing a semicolon',
|
||||||
|
code: 'MISSING_SEMICOLON',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip JSON comment checks if comments are already removed
|
||||||
|
|
||||||
|
// 5. Check for common typos
|
||||||
|
if (line.match(/FOREIGN\s+KEY\s*\(/i) && !line.includes('REFERENCES')) {
|
||||||
|
// Check if REFERENCES is on the next line
|
||||||
|
if (i + 1 >= lines.length || !lines[i + 1].includes('REFERENCES')) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'FOREIGN KEY constraint is missing REFERENCES clause',
|
||||||
|
code: 'MISSING_REFERENCES',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Check for mismatched quotes - but be smart about it
|
||||||
|
// Skip lines that are comments or contain escaped quotes
|
||||||
|
if (!line.trim().startsWith('--') && !line.trim().startsWith('#')) {
|
||||||
|
// Remove escaped quotes before counting
|
||||||
|
const cleanLine = line
|
||||||
|
.replace(/\\'/g, '')
|
||||||
|
.replace(/\\"/g, '')
|
||||||
|
.replace(/\\`/g, '');
|
||||||
|
|
||||||
|
// Also remove quoted strings to avoid false positives
|
||||||
|
const withoutStrings = cleanLine
|
||||||
|
.replace(/'[^']*'/g, '')
|
||||||
|
.replace(/"[^"]*"/g, '')
|
||||||
|
.replace(/`[^`]*`/g, '');
|
||||||
|
|
||||||
|
// Now count unmatched quotes
|
||||||
|
const singleQuotes = (withoutStrings.match(/'/g) || []).length;
|
||||||
|
const doubleQuotes = (withoutStrings.match(/"/g) || []).length;
|
||||||
|
const backticks = (withoutStrings.match(/`/g) || []).length;
|
||||||
|
|
||||||
|
if (singleQuotes > 0 || doubleQuotes > 0 || backticks > 0) {
|
||||||
|
warnings.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: 'Possible mismatched quotes detected',
|
||||||
|
code: 'MISMATCHED_QUOTES',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unsupported MySQL features
|
||||||
|
const unsupportedFeatures = [
|
||||||
|
{ pattern: /CREATE\s+TRIGGER/i, feature: 'Triggers' },
|
||||||
|
{ pattern: /CREATE\s+PROCEDURE/i, feature: 'Stored Procedures' },
|
||||||
|
{ pattern: /CREATE\s+FUNCTION/i, feature: 'Functions' },
|
||||||
|
{ pattern: /CREATE\s+EVENT/i, feature: 'Events' },
|
||||||
|
{ pattern: /CREATE\s+VIEW/i, feature: 'Views' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { pattern, feature } of unsupportedFeatures) {
|
||||||
|
if (pattern.test(sql)) {
|
||||||
|
warnings.push({
|
||||||
|
message: `${feature} are not supported and will be ignored during import`,
|
||||||
|
code: `UNSUPPORTED_${feature.toUpperCase().replace(' ', '_')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
canAutoFix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidate duplicate warnings and format them nicely
|
||||||
|
*/
|
||||||
|
function consolidateWarnings(warnings: MySQLValidationWarning[]): {
|
||||||
|
message: string;
|
||||||
|
count: number;
|
||||||
|
lines?: number[];
|
||||||
|
}[] {
|
||||||
|
const warningMap = new Map<string, { count: number; lines: number[] }>();
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
const key = warning.code || warning.message;
|
||||||
|
if (!warningMap.has(key)) {
|
||||||
|
warningMap.set(key, { count: 0, lines: [] });
|
||||||
|
}
|
||||||
|
const entry = warningMap.get(key)!;
|
||||||
|
entry.count++;
|
||||||
|
if (warning.line) {
|
||||||
|
entry.lines.push(warning.line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const consolidated: { message: string; count: number; lines?: number[] }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
for (const [key, value] of warningMap) {
|
||||||
|
// Find the original warning to get the message
|
||||||
|
const originalWarning = warnings.find(
|
||||||
|
(w) => (w.code || w.message) === key
|
||||||
|
)!;
|
||||||
|
consolidated.push({
|
||||||
|
message: originalWarning.message,
|
||||||
|
count: value.count,
|
||||||
|
lines: value.lines.length > 0 ? value.lines : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by count (most frequent first)
|
||||||
|
return consolidated.sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValidationMessage(result: MySQLValidationResult): string {
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
if (!result.isValid) {
|
||||||
|
messages.push('❌ MySQL/MariaDB syntax validation failed:\n');
|
||||||
|
|
||||||
|
for (const error of result.errors) {
|
||||||
|
messages.push(
|
||||||
|
` Error${error.line ? ` at line ${error.line}` : ''}: ${error.message}`
|
||||||
|
);
|
||||||
|
if (error.suggestion) {
|
||||||
|
messages.push(` 💡 Suggestion: ${error.suggestion}`);
|
||||||
|
}
|
||||||
|
messages.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.warnings.length > 0) {
|
||||||
|
const consolidated = consolidateWarnings(result.warnings);
|
||||||
|
|
||||||
|
// Only show if there are a reasonable number of warnings
|
||||||
|
if (consolidated.length <= 5) {
|
||||||
|
messages.push('⚠️ Import Notes:\n');
|
||||||
|
for (const warning of consolidated) {
|
||||||
|
if (warning.count > 1) {
|
||||||
|
messages.push(
|
||||||
|
` • ${warning.message} (${warning.count} occurrences)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
messages.push(` • ${warning.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For many warnings, just show a summary
|
||||||
|
const totalWarnings = result.warnings.length;
|
||||||
|
messages.push(
|
||||||
|
`⚠️ Import completed with ${totalWarnings} warnings:\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show top 3 most common warnings
|
||||||
|
const topWarnings = consolidated.slice(0, 3);
|
||||||
|
for (const warning of topWarnings) {
|
||||||
|
messages.push(` • ${warning.message} (${warning.count}x)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consolidated.length > 3) {
|
||||||
|
messages.push(
|
||||||
|
` • ...and ${consolidated.length - 3} other warning types`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.join('\n');
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
|||||||
|
export interface SQLiteValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: SQLiteValidationError[];
|
||||||
|
warnings: SQLiteValidationWarning[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SQLiteValidationError {
|
||||||
|
line?: number;
|
||||||
|
column?: number;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SQLiteValidationWarning {
|
||||||
|
line?: number;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSQLiteSyntax(sql: string): SQLiteValidationResult {
|
||||||
|
const errors: SQLiteValidationError[] = [];
|
||||||
|
const warnings: SQLiteValidationWarning[] = [];
|
||||||
|
|
||||||
|
const lines = sql.split('\n');
|
||||||
|
|
||||||
|
// Check for common SQLite syntax issues
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const lineNum = i + 1;
|
||||||
|
|
||||||
|
// 1. Check for square brackets (SQL Server style)
|
||||||
|
if (/\[[^\]]+\]/.test(line) && !line.includes('--')) {
|
||||||
|
warnings.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'SQLite supports square brackets but double quotes are preferred for identifiers',
|
||||||
|
code: 'SQUARE_BRACKETS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for unsupported data types
|
||||||
|
const unsupportedTypes = [
|
||||||
|
{ type: 'DATETIME2', suggestion: 'Use DATETIME or TEXT' },
|
||||||
|
{ type: 'NVARCHAR', suggestion: 'Use TEXT' },
|
||||||
|
{ type: 'MONEY', suggestion: 'Use REAL or NUMERIC' },
|
||||||
|
{ type: 'UNIQUEIDENTIFIER', suggestion: 'Use TEXT' },
|
||||||
|
{ type: 'XML', suggestion: 'Use TEXT' },
|
||||||
|
{ type: 'GEOGRAPHY', suggestion: 'Use TEXT or BLOB' },
|
||||||
|
{ type: 'GEOMETRY', suggestion: 'Use TEXT or BLOB' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { type, suggestion } of unsupportedTypes) {
|
||||||
|
const regex = new RegExp(`\\b${type}\\b`, 'i');
|
||||||
|
if (regex.test(line)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `SQLite does not support ${type} data type`,
|
||||||
|
code: `UNSUPPORTED_TYPE_${type}`,
|
||||||
|
suggestion: suggestion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check for CASCADE DELETE/UPDATE (limited support)
|
||||||
|
if (/ON\s+(DELETE|UPDATE)\s+CASCADE/i.test(line)) {
|
||||||
|
warnings.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'CASCADE actions require foreign keys to be enabled in SQLite (PRAGMA foreign_keys = ON)',
|
||||||
|
code: 'CASCADE_REQUIRES_FK',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check for multiple primary keys in CREATE TABLE
|
||||||
|
if (/PRIMARY\s+KEY/i.test(line)) {
|
||||||
|
// Check if this is a column-level primary key
|
||||||
|
const beforePK = line.substring(0, line.search(/PRIMARY\s+KEY/i));
|
||||||
|
if (beforePK.trim() && !beforePK.includes('CONSTRAINT')) {
|
||||||
|
// This is likely a column-level PRIMARY KEY
|
||||||
|
// Check if there's already been a PRIMARY KEY in this table
|
||||||
|
let tableStartLine = i;
|
||||||
|
for (let j = i - 1; j >= 0; j--) {
|
||||||
|
if (/CREATE\s+TABLE/i.test(lines[j])) {
|
||||||
|
tableStartLine = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count PRIMARY KEY occurrences in this table
|
||||||
|
let pkCount = 0;
|
||||||
|
for (let j = tableStartLine; j <= i; j++) {
|
||||||
|
if (/PRIMARY\s+KEY/i.test(lines[j])) {
|
||||||
|
pkCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pkCount > 1) {
|
||||||
|
warnings.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'Multiple PRIMARY KEY definitions found. Consider using a composite primary key.',
|
||||||
|
code: 'MULTIPLE_PRIMARY_KEYS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Check for WITH clause (not fully supported)
|
||||||
|
if (/\bWITH\s+\(/i.test(line) && /CREATE\s+TABLE/i.test(line)) {
|
||||||
|
warnings.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'WITH clause in CREATE TABLE has limited support in SQLite',
|
||||||
|
code: 'LIMITED_WITH_SUPPORT',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unsupported SQLite features in DDL import
|
||||||
|
const unsupportedFeatures = [
|
||||||
|
{ pattern: /CREATE\s+PROCEDURE/i, feature: 'Stored Procedures' },
|
||||||
|
{ pattern: /CREATE\s+FUNCTION/i, feature: 'User-defined Functions' },
|
||||||
|
{ pattern: /DECLARE\s+@/i, feature: 'Variables' },
|
||||||
|
{ pattern: /CREATE\s+VIEW/i, feature: 'Views' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { pattern, feature } of unsupportedFeatures) {
|
||||||
|
if (pattern.test(sql)) {
|
||||||
|
warnings.push({
|
||||||
|
message: `${feature} are not supported and will be ignored during import`,
|
||||||
|
code: `UNSUPPORTED_${feature.toUpperCase().replace(' ', '_')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite-specific warnings
|
||||||
|
if (/ALTER\s+TABLE.*DROP\s+COLUMN/i.test(sql)) {
|
||||||
|
warnings.push({
|
||||||
|
message: 'ALTER TABLE DROP COLUMN requires SQLite 3.35.0 or later',
|
||||||
|
code: 'DROP_COLUMN_VERSION',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export interface SQLServerValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: SQLServerValidationError[];
|
||||||
|
warnings: SQLServerValidationWarning[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SQLServerValidationError {
|
||||||
|
line?: number;
|
||||||
|
column?: number;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SQLServerValidationWarning {
|
||||||
|
line?: number;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSQLServerSyntax(
|
||||||
|
sql: string
|
||||||
|
): SQLServerValidationResult {
|
||||||
|
const errors: SQLServerValidationError[] = [];
|
||||||
|
const warnings: SQLServerValidationWarning[] = [];
|
||||||
|
|
||||||
|
const lines = sql.split('\n');
|
||||||
|
|
||||||
|
// Check for common SQL Server syntax issues
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const lineNum = i + 1;
|
||||||
|
|
||||||
|
// 1. Check for MySQL-style backticks (should use square brackets)
|
||||||
|
if (line.includes('`')) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'SQL Server uses square brackets [name] instead of backticks `name` for identifiers',
|
||||||
|
code: 'INVALID_IDENTIFIER_QUOTES',
|
||||||
|
suggestion:
|
||||||
|
'Replace backticks with square brackets: `name` → [name]',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for PostgreSQL-style :: cast operator
|
||||||
|
if (line.includes('::')) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'SQL Server uses CAST() or CONVERT() instead of :: for type casting',
|
||||||
|
code: 'INVALID_CAST_OPERATOR',
|
||||||
|
suggestion:
|
||||||
|
'Use CAST(expression AS type) or CONVERT(type, expression)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check for AUTO_INCREMENT (MySQL style)
|
||||||
|
if (/AUTO_INCREMENT/i.test(line)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: 'SQL Server uses IDENTITY instead of AUTO_INCREMENT',
|
||||||
|
code: 'INVALID_AUTO_INCREMENT',
|
||||||
|
suggestion: 'Replace AUTO_INCREMENT with IDENTITY(1,1)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check for LIMIT clause (not supported in SQL Server)
|
||||||
|
if (/\bLIMIT\s+\d+/i.test(line)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: 'SQL Server does not support LIMIT clause',
|
||||||
|
code: 'UNSUPPORTED_LIMIT',
|
||||||
|
suggestion:
|
||||||
|
'Use TOP clause instead: SELECT TOP 10 * FROM table',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Check for BOOLEAN type (not native in SQL Server)
|
||||||
|
if (/\bBOOLEAN\b/i.test(line)) {
|
||||||
|
warnings.push({
|
||||||
|
line: lineNum,
|
||||||
|
message:
|
||||||
|
'SQL Server does not have a native BOOLEAN type. Use BIT instead.',
|
||||||
|
code: 'NO_BOOLEAN_TYPE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unsupported SQL Server features in DDL import
|
||||||
|
const unsupportedFeatures = [
|
||||||
|
{ pattern: /CREATE\s+PROCEDURE/i, feature: 'Stored Procedures' },
|
||||||
|
{ pattern: /CREATE\s+FUNCTION/i, feature: 'Functions' },
|
||||||
|
{ pattern: /CREATE\s+TRIGGER/i, feature: 'Triggers' },
|
||||||
|
{ pattern: /CREATE\s+VIEW/i, feature: 'Views' },
|
||||||
|
{ pattern: /CREATE\s+ASSEMBLY/i, feature: 'Assemblies' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { pattern, feature } of unsupportedFeatures) {
|
||||||
|
if (pattern.test(sql)) {
|
||||||
|
warnings.push({
|
||||||
|
message: `${feature} are not supported and will be ignored during import`,
|
||||||
|
code: `UNSUPPORTED_${feature.toUpperCase().replace(' ', '_')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -127,8 +127,17 @@ export function detectDatabaseType(sqlContent: string): DatabaseType | null {
|
|||||||
return DatabaseType.SQL_SERVER;
|
return DatabaseType.SQL_SERVER;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for MySQL dump format
|
// Check for MySQL/MariaDB dump format
|
||||||
if (isMySQLFormat(sqlContent)) {
|
if (isMySQLFormat(sqlContent)) {
|
||||||
|
// Try to detect if it's specifically MariaDB
|
||||||
|
if (
|
||||||
|
sqlContent.includes('MariaDB dump') ||
|
||||||
|
sqlContent.includes('/*!100100') ||
|
||||||
|
sqlContent.includes('ENGINE=Aria') ||
|
||||||
|
sqlContent.includes('ENGINE=COLUMNSTORE')
|
||||||
|
) {
|
||||||
|
return DatabaseType.MARIADB;
|
||||||
|
}
|
||||||
return DatabaseType.MYSQL;
|
return DatabaseType.MYSQL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,9 +208,10 @@ export async function sqlImportToDiagram({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case DatabaseType.MYSQL:
|
case DatabaseType.MYSQL:
|
||||||
// Check if the SQL is from MySQL dump and use the appropriate parser
|
case DatabaseType.MARIADB:
|
||||||
|
// Check if the SQL is from MySQL/MariaDB dump and use the appropriate parser
|
||||||
|
// MariaDB uses the same parser as MySQL due to high compatibility
|
||||||
parserResult = await fromMySQL(sqlContent);
|
parserResult = await fromMySQL(sqlContent);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case DatabaseType.SQL_SERVER:
|
case DatabaseType.SQL_SERVER:
|
||||||
parserResult = await fromSQLServer(sqlContent);
|
parserResult = await fromSQLServer(sqlContent);
|
||||||
@@ -273,8 +283,9 @@ export async function parseSQLError({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case DatabaseType.MYSQL:
|
case DatabaseType.MYSQL:
|
||||||
|
case DatabaseType.MARIADB:
|
||||||
|
// MariaDB uses the same parser as MySQL
|
||||||
await fromMySQL(sqlContent);
|
await fromMySQL(sqlContent);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case DatabaseType.SQL_SERVER:
|
case DatabaseType.SQL_SERVER:
|
||||||
// SQL Server validation
|
// SQL Server validation
|
||||||
|
|||||||
181
src/lib/data/sql-import/unified-sql-validator.ts
Normal file
181
src/lib/data/sql-import/unified-sql-validator.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Unified SQL Validator that delegates to appropriate dialect validators
|
||||||
|
* Ensures consistent error format with clickable line numbers for all dialects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseType } from '@/lib/domain/database-type';
|
||||||
|
import {
|
||||||
|
validatePostgreSQLSyntax,
|
||||||
|
type ValidationResult,
|
||||||
|
} from './sql-validator';
|
||||||
|
import { validateMySQLSyntax } from './dialect-importers/mysql/mysql-validator';
|
||||||
|
import { validateSQLServerSyntax } from './dialect-importers/sqlserver/sqlserver-validator';
|
||||||
|
import { validateSQLiteSyntax } from './dialect-importers/sqlite/sqlite-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate SQL based on the database type
|
||||||
|
* Returns a unified ValidationResult format for consistent UI display
|
||||||
|
*/
|
||||||
|
export function validateSQL(
|
||||||
|
sql: string,
|
||||||
|
databaseType: DatabaseType
|
||||||
|
): ValidationResult {
|
||||||
|
switch (databaseType) {
|
||||||
|
case DatabaseType.POSTGRESQL:
|
||||||
|
// PostgreSQL already returns the correct format
|
||||||
|
return validatePostgreSQLSyntax(sql);
|
||||||
|
|
||||||
|
case DatabaseType.MYSQL: {
|
||||||
|
// Convert MySQL validation result to standard format
|
||||||
|
const mysqlResult = validateMySQLSyntax(sql);
|
||||||
|
|
||||||
|
// If there are only warnings (no errors), consolidate them for cleaner display
|
||||||
|
let warnings = mysqlResult.warnings.map((warn) => ({
|
||||||
|
message: warn.message,
|
||||||
|
type: 'compatibility' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (mysqlResult.isValid && mysqlResult.warnings.length > 10) {
|
||||||
|
// Too many warnings, just show a summary
|
||||||
|
const warningTypes = new Map<string, number>();
|
||||||
|
for (const warn of mysqlResult.warnings) {
|
||||||
|
const type = warn.code || 'other';
|
||||||
|
warningTypes.set(type, (warningTypes.get(type) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings = [
|
||||||
|
{
|
||||||
|
message: `Import successful. Found ${mysqlResult.warnings.length} minor syntax notes (mostly quote formatting).`,
|
||||||
|
type: 'compatibility' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: mysqlResult.isValid,
|
||||||
|
errors: mysqlResult.errors.map((err) => ({
|
||||||
|
line: err.line || 1,
|
||||||
|
column: err.column,
|
||||||
|
message: err.message,
|
||||||
|
type: 'syntax' as const,
|
||||||
|
suggestion: err.suggestion,
|
||||||
|
})),
|
||||||
|
warnings,
|
||||||
|
fixedSQL: undefined,
|
||||||
|
tableCount: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case DatabaseType.SQL_SERVER: {
|
||||||
|
// Convert SQL Server validation result to standard format
|
||||||
|
const sqlServerResult = validateSQLServerSyntax(sql);
|
||||||
|
return {
|
||||||
|
isValid: sqlServerResult.isValid,
|
||||||
|
errors: sqlServerResult.errors.map((err) => ({
|
||||||
|
line: err.line || 1,
|
||||||
|
column: err.column,
|
||||||
|
message: err.message,
|
||||||
|
type: 'syntax' as const,
|
||||||
|
suggestion: err.suggestion,
|
||||||
|
})),
|
||||||
|
warnings: sqlServerResult.warnings.map((warn) => ({
|
||||||
|
message: warn.message,
|
||||||
|
type: 'compatibility' as const,
|
||||||
|
})),
|
||||||
|
fixedSQL: undefined,
|
||||||
|
tableCount: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case DatabaseType.SQLITE: {
|
||||||
|
// Convert SQLite validation result to standard format
|
||||||
|
const sqliteResult = validateSQLiteSyntax(sql);
|
||||||
|
return {
|
||||||
|
isValid: sqliteResult.isValid,
|
||||||
|
errors: sqliteResult.errors.map((err) => ({
|
||||||
|
line: err.line || 1,
|
||||||
|
column: err.column,
|
||||||
|
message: err.message,
|
||||||
|
type: 'syntax' as const,
|
||||||
|
suggestion: err.suggestion,
|
||||||
|
})),
|
||||||
|
warnings: sqliteResult.warnings.map((warn) => ({
|
||||||
|
message: warn.message,
|
||||||
|
type: 'compatibility' as const,
|
||||||
|
})),
|
||||||
|
fixedSQL: undefined,
|
||||||
|
tableCount: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case DatabaseType.MARIADB:
|
||||||
|
// MariaDB uses MySQL validator
|
||||||
|
return validateSQL(sql, DatabaseType.MYSQL);
|
||||||
|
|
||||||
|
case DatabaseType.GENERIC:
|
||||||
|
// For generic, try to detect the type or use basic validation
|
||||||
|
return {
|
||||||
|
isValid: true, // Let the parser determine validity
|
||||||
|
errors: [],
|
||||||
|
warnings: [
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Using generic SQL validation. Some dialect-specific issues may not be detected.',
|
||||||
|
type: 'compatibility',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract line number from parser error messages
|
||||||
|
* Used as fallback when dialect validators don't catch errors
|
||||||
|
*/
|
||||||
|
export function extractLineFromError(errorMessage: string): number | undefined {
|
||||||
|
// Common patterns for line numbers in error messages
|
||||||
|
const patterns = [
|
||||||
|
/line\s+(\d+)/i,
|
||||||
|
/Line\s+(\d+)/,
|
||||||
|
/at line (\d+)/i,
|
||||||
|
/\((\d+):\d+\)/, // (line:column) format
|
||||||
|
/row (\d+)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = errorMessage.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return parseInt(match[1], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format parser errors into ValidationResult format
|
||||||
|
* This ensures parser errors can be displayed with clickable line numbers
|
||||||
|
*/
|
||||||
|
export function formatParserError(errorMessage: string): ValidationResult {
|
||||||
|
const line = extractLineFromError(errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
line: line || 1,
|
||||||
|
message: errorMessage,
|
||||||
|
type: 'parser' as const,
|
||||||
|
suggestion: 'Check your SQL syntax near the reported line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user