diff --git a/src/components/EditorHeader/Modal/Modal.jsx b/src/components/EditorHeader/Modal/Modal.jsx index 86b82e081..5593a81e0 100644 --- a/src/components/EditorHeader/Modal/Modal.jsx +++ b/src/components/EditorHeader/Modal/Modal.jsx @@ -40,6 +40,7 @@ import { useTranslation } from "react-i18next"; import { importSQL } from "../../../utils/importSQL"; import { databases } from "../../../data/databases"; import { isRtl } from "../../../i18n/utils/rtl"; +import { parseSQL } from "../../../utils/ddlParser"; const extensionToLanguage = { md: "markdown", @@ -143,13 +144,16 @@ export default function Modal({ const parseSQLAndLoadDiagram = () => { const targetDatabase = database === DB.GENERIC ? importDb : database; - + console.log(targetDatabase); + let ast = null; try { if (targetDatabase === DB.ORACLESQL) { const oracleParser = new OracleParser(); ast = oracleParser.parse(importSource.src); + }else if(targetDatabase === DB.POSTGRES){ + ast = parseSQL(importSource.src).toAst(targetDatabase); } else { const parser = new Parser(); @@ -172,7 +176,8 @@ export default function Modal({ database === DB.GENERIC ? importDb : database, database, ); - + console.log(diagramData); + if (importSource.overwrite) { setTables(diagramData.tables); setRelationships(diagramData.relationships); diff --git a/src/utils/ddlParser.js b/src/utils/ddlParser.js new file mode 100644 index 000000000..e47854723 --- /dev/null +++ b/src/utils/ddlParser.js @@ -0,0 +1,1024 @@ +// universalDdlParser.js +// Universal DDL tokenizer + recursive-descent parser +// Supports extended PostgreSQL DDL: CREATE TABLE/TYPE(INUM)/INDEX/VIEW/SEQUENCE/DOMAIN/COMMENT/ALTER/DROP +// Generates NSP-compatible AST for PostgreSQL (others reuse or adapt) + +///// Helpers ///// +function getLineCol(sql, pos) { + const lines = sql.substring(0, pos).split('\n'); + const line = lines.length; + const col = lines[lines.length - 1].length + 1; + return { line, col }; +} + +class ParserError extends Error { + constructor(sql, pos, expected, found) { + const { line, col } = getLineCol(sql, pos); + const msg = `SyntaxError [Ln ${line}, Col ${col}]: Expected ${expected} but ${found} found.`; + super(msg); + this.name = 'SyntaxError'; + this.line = line; + this.col = col; + } +} + +///// Tokenizer ///// +function tokenize(sql) { + // remove single-line and block comments + sql = sql.replace(/--[^\n\r]*/g, ''); + sql = sql.replace(/\/\*[\s\S]*?\*\//g, ''); + const out = []; + let i = 0; + const peek = () => sql[i]; + const next = () => sql[i++]; + const push = (type, val) => out.push({ type, val, pos: i }); + + while (i < sql.length) { + const ch = peek(); + if (/\s/.test(ch)) { next(); continue; } + if (ch === '"' || ch === '`' || ch === "'") { + const q = next(); + let buf = ''; + while (peek() && peek() !== q) { + // support escaped quote by doubling (simple) + if (peek() === '\\') { next(); if (peek()) buf += next(); continue; } + buf += next(); + } + if (peek() === q) next(); + // single quote strings we keep as ID token (value) but mark type as STRING for clarity + if (q === "'") push('STRING', buf); + else push('ID', buf); + continue; + } + if (/\d/.test(ch)) { + let buf = ''; + while (/\d/.test(peek())) buf += next(); + if (peek() === '.' && /\d/.test(sql[i + 1])) { + buf += next(); + while (/\d/.test(peek())) buf += next(); + } + push('NUM', buf); + continue; + } + if ('(),;.*[]'.includes(ch)) { + push(ch, next()); + continue; + } + if (/[A-Za-z_]/.test(ch)) { + let buf = ''; + while (/[A-Za-z0-9_$]/.test(peek())) buf += next(); + buf = normalizeKw(buf); + push(buf, buf); + continue; + } + // unknown char, skip + next(); + } + return { tokens: out, sql }; +} + +function normalizeKw(kw) { + const KEYWORDS = new Set( + 'CREATE,ALTER,DROP,TABLE,SCHEMA,INDEX,SEQUENCE,FUNCTION,VIEW,TRIGGER,DOMAIN,TYPE,ENUM,CONSTRAINT,PRIMARY,FOREIGN,KEY,UNIQUE,CHECK,REFERENCES,ON,UPDATE,DELETE,ADD,COLUMN,IF,EXISTS,NOT,NULL,DEFAULT,ASC,DESC,WITH,WITHOUT,SET,RETURNING,AS,AND,OR,XOR,TRUE,FALSE,NOW,CURRENT_TIMESTAMP,CURRENT_DATE,CURRENT_TIME,LOCALTIME,LOCALTIMESTAMP,EXTRACT,YEAR,MONTH,DAY,HOUR,MINUTE,SECOND,TIMEZONE,INTERVAL,CASCADE,RESTRICT,DEFERRABLE,INITIALLY,DEFERRED,IMMEDIATE,ENABLE,DISABLE,VALIDATE,NOVALIDATE,RELY,NORELY,MATCH,FULL,PARTIAL,SIMPLE,USER,CURRENT_USER,SESSION_USER,CURRENT_CATALOG,CURRENT_SCHEMA,ARRAY,JSON,JSONB,UUID,MONEY,NUMERIC,DECIMAL,REAL,DOUBLE,PRECISION,FLOAT,SMALLINT,INTEGER,BIGINT,INT2,INT4,INT8,SERIAL,SERIAL2,SERIAL4,SERIAL8,BIGSERIAL,SMALLSERIAL,VARCHAR,CHARACTER,CHAR,TEXT,BYTEA,BIT,VARYING,VARBIT,TIMESTAMP,TIMESTAMPTZ,TIME,TIMETZ,DATE,BOOLEAN,BOOL,INET,CIDR,MACADDR,TSVECTOR,TSQUERY,XML,POINT,LINE,LSEG,BOX,PATH,POLYGON,CIRCLE,INT4RANGE,INT8RANGE,NUMRANGE,TSRANGE,TSTZRANGE,DATERANGE,LTREE,CITEXT,IPADDRESS,GEOMETRY,GEOGRAPHY,RASTER,REGCLASS,REGTYPE,REGPROC,REGOPER,REGOPERATOR,REGCONFIG,REGDICTIONARY,REGROLE,REGNAMESPACE' + .split(',') + ); + return KEYWORDS.has(kw.toUpperCase()) ? kw.toUpperCase() : kw; +} + +///// Parser ///// +class Parser { + constructor(tokens, sql) { + this.t = tokens; + this.i = 0; + this.sql = sql; + } + peek() { return this.t[this.i]; } + consume(expected) { + const cur = this.peek(); + if (!cur) throw new ParserError(this.sql, this.t[this.t.length - 1]?.pos || 0, `"${expected}"`, 'EOF'); + if (expected && cur.type !== expected && cur.val !== expected) { + throw new ParserError(this.sql, cur.pos, `"${expected}"`, `"${cur.val}"`); + } + this.i++; + return cur; + } + parse() { + const statements = []; + while (this.peek()) statements.push(this.stmt()); + return statements; + } + stmt() { + const cur = this.consume(); + switch (cur.type) { + case 'CREATE': return this.createStmt(); + case 'ALTER': return this.alterStmt(); + case 'DROP': return this.dropStmt(); + case 'COMMENT': return this.commentStmt(); + default: + throw new ParserError(this.sql, cur.pos, `"CREATE" | "ALTER" | "DROP" | "COMMENT"`, `"${cur.val}"`); + } + } + + /* ---------- CREATE ---------- */ + createStmt() { + // next token indicates what: TABLE | TYPE | INDEX | VIEW | SEQUENCE | SCHEMA | DOMAIN | MATERIALIZED + const what = this.consume().type; + const ifNot = this.optional('IF') && (this.consume('NOT'), this.consume('EXISTS'), true); + // name may be identifier or schema-qualified (we keep as simple id for now) + const name = this.id(); + let rest = {}; + + // dispatch per object + if (what === 'TABLE') rest = this.createTableTail(name); + else if (what === 'TYPE') rest = this.createTypeTail(name); + else if (what === 'INDEX') rest = this.createIndexTail(name); + else if (what === 'VIEW') rest = this.createViewTail({ materialized: false }); + else if (what === 'MATERIALIZED') { + // CREATE MATERIALIZED VIEW name AS ... + this.consume('VIEW'); + rest = this.createViewTail({ materialized: true }); + } else if (what === 'SEQUENCE') rest = this.createSequenceTail(name); + else if (what === 'SCHEMA') rest = {}; // create schema name + else if (what === 'DOMAIN') rest = this.createDomainTail(name); + else { + // fallback: skip to semicolon/terminator and put body + rest = { body: this.skipToTerminator() }; + } + + this.optional(';'); + return { type: 'CREATE', what, name, ifNot, ...rest }; + } + + createTableTail() { + // expects ( ... ) for columns and table constraints + this.consume('('); + const columns = [], constraints = []; + do { + if (this.peek().val === ',' && columns.length) { this.consume(','); continue; } + if (this.isConstraintStart()) constraints.push(this.constraint()); + else columns.push(this.columnDef()); + } while (!this.optional(')')); + return { columns, constraints }; + } + + createTypeTail() { + // supports: AS ENUM ('a','b', ...) + if (this.optional('AS')) { + if (this.optional('ENUM')) { + this.consume('('); + const values = []; + while (!this.optional(')')) { + const vtoken = this.consume(); + if (vtoken.type !== 'STRING' && vtoken.type !== 'ID') { + throw new ParserError(this.sql, vtoken.pos, 'string', `"${vtoken.val}"`); + } + values.push(vtoken.val); + this.optional(','); + } + return { kind: 'ENUM', values }; + } + } + // fallback: body + return { body: this.skipToTerminator() }; + } + + createIndexTail() { + // optionally UNIQUE + let unique = false; + // name already consumed, we expect maybe UNIQUE before ON in some dialects + // if the token we consumed as 'name' was actually UNIQUE we would have consumed UNIQUE as name; keep simple: allow UNIQUE keyword here + const prev = this.peek(); + if (prev && prev.type === 'UNIQUE') { unique = true; this.consume('UNIQUE'); } + // syntaxes: ON table (col, ...) + if (this.optional('ON')) { + const table = this.id(); + this.consume('('); + const columns = this.idList(); + this.consume(')'); + return { unique, table, columns }; + } + // fallback: body + return { body: this.skipToTerminator() }; + } + + createViewTail(opts = {}) { + // CREATE [MATERIALIZED] VIEW name AS