@@ -20,6 +20,8 @@ import type {
2020} from "../types" ;
2121
2222const IDENT = String . raw `(?:\`[^\`]+\`|"[^"]+"|\[[^\]]+\]|[\w一-龥]+)` ;
23+ // schema-qualified 标识符:a / a.b / a.b.c。每段都允许带引号 / 反引号 / 方括号。
24+ const QUALIFIED_IDENT = String . raw `${ IDENT } (?:\.${ IDENT } )*` ;
2325
2426const cleanIdentifier = ( raw : string ) : string => {
2527 const last = raw
@@ -251,15 +253,39 @@ interface RefTarget {
251253}
252254
253255const parseRefTarget = ( raw : string ) : RefTarget | null => {
254- const cleaned = raw . trim ( ) . replace ( / ^ [ ( , ] | [ ) , ] $ / g, "" ) ;
256+ // 仅去掉首尾游离的逗号(来自 splitTopLevelCommas 拆开内联 ref 时残留)。
257+ // 不要再剥圆括号 —— 复合外键 `table.(col_a, col_b)` 的右括号会被误吃,
258+ // 历史上写过 `^[(,]|[),]$` 是按"防御性清洗"思路写的,现在已无必要。
259+ let cleaned = raw . trim ( ) . replace ( / ^ , + | , + $ / g, "" ) . trim ( ) ;
260+ // 去掉尾部 `[delete: cascade, update: cascade]` 这类设置块。
261+ // Ref: a.b > c.d [...] 时,右目标会粘上 `[...]`,必须先剥掉再分段。
262+ const lb = indexOfUnquoted ( cleaned , "[" ) ;
263+ if ( lb !== - 1 ) {
264+ const rb = findMatchingBracket ( cleaned , lb ) ;
265+ if ( rb !== - 1 ) {
266+ cleaned = ( cleaned . slice ( 0 , lb ) + " " + cleaned . slice ( rb + 1 ) ) . trim ( ) ;
267+ }
268+ }
255269 if ( ! cleaned ) return null ;
256270 const segs = cleaned
257271 . split ( "." )
258272 . map ( ( s ) => s . trim ( ) )
259273 . filter ( Boolean )
260274 . map ( ( s ) => s . replace ( / ^ [ ` " \[ ] | [ ` " \] ] $ / g, "" ) ) ;
261275 if ( segs . length < 2 ) return null ;
262- return { table : segs [ segs . length - 2 ] , column : segs [ segs . length - 1 ] } ;
276+ // 复合列 `(col_a, col_b)` —— 去掉外层括号当作 label,避免出现 `(col_a, col_b)`
277+ // 这种带括号的边标签。table 与 column 仍按原始 segs 取,column 拿掉括号后
278+ // 用于显示。
279+ let column = segs [ segs . length - 1 ] ;
280+ const composite = column . match ( / ^ \( \s * ( [ \s \S ] + ?) \s * \) $ / ) ;
281+ if ( composite ) {
282+ column = composite [ 1 ]
283+ . split ( "," )
284+ . map ( ( s ) => s . trim ( ) )
285+ . filter ( Boolean )
286+ . join ( ", " ) ;
287+ }
288+ return { table : segs [ segs . length - 2 ] , column } ;
263289} ;
264290
265291const parseInlineRef = (
@@ -315,13 +341,16 @@ const parseColumnLine = (line: string): ColumnLineResult => {
315341 const type = m [ 2 ] . trim ( ) . replace ( / \s + / g, " " ) ;
316342
317343 let isPrimaryKey = false ;
344+ let isUnique = false ;
318345 let inlineRef : ColumnLineResult [ "inlineRef" ] = null ;
319346 let comment : string | undefined ;
320347
321348 if ( attrsRaw ) {
322349 for ( const attr of parseColumnAttrs ( attrsRaw ) ) {
323350 if ( attr . key === "pk" || attr . key === "primary key" ) {
324351 isPrimaryKey = true ;
352+ } else if ( attr . key === "unique" ) {
353+ isUnique = true ;
325354 } else if ( attr . key === "ref" && attr . value ) {
326355 const r = parseInlineRef ( attr . value ) ;
327356 if ( r ) inlineRef = r ;
@@ -332,6 +361,7 @@ const parseColumnLine = (line: string): ColumnLineResult => {
332361 }
333362
334363 const column : ParsedColumn = { name, type, isPrimaryKey } ;
364+ if ( isUnique ) column . isUnique = true ;
335365 if ( comment !== undefined ) column . comment = comment ;
336366 return { column, inlineRef } ;
337367} ;
@@ -383,16 +413,29 @@ interface TopStatement {
383413}
384414
385415const classifyHeader = ( header : string ) : TopStatement [ "kind" ] => {
416+ // 注意:先于 `Table\b` 判 TablePartial / TableGroup,否则前者会被吞。
417+ if ( / ^ T a b l e P a r t i a l \b / i. test ( header ) ) return "unknown" ;
418+ if ( / ^ T a b l e G r o u p \b / i. test ( header ) ) return "tablegroup" ;
386419 if ( / ^ T a b l e \b / i. test ( header ) ) return "table" ;
387- if ( / ^ R e f \b \s * (?: [ \w 一 - 龥 ] + \s * ) ? : / i. test ( header ) ) return "ref" ;
420+ // Ref 短句:`Ref:` 或 `Ref name:`,可能换行后才进入正文
421+ if ( / ^ R e f \b [ ^ { ] * : / i. test ( header ) ) return "ref" ;
388422 if ( / ^ R e f \b / i. test ( header ) ) return "refblock" ;
389423 if ( / ^ E n u m \b / i. test ( header ) ) return "enum" ;
390424 if ( / ^ P r o j e c t \b / i. test ( header ) ) return "project" ;
391- if ( / ^ T a b l e G r o u p \b / i. test ( header ) ) return "tablegroup" ;
425+ if ( / ^ D i a g r a m V i e w \b / i. test ( header ) ) return "unknown" ;
426+ if ( / ^ r e c o r d s \b / i. test ( header ) ) return "unknown" ;
427+ if ( / ^ N o t e \b / i. test ( header ) ) return "unknown" ;
392428 return "unknown" ;
393429} ;
394430
395- const TOP_KEYWORD_RE = / ^ ( T a b l e | R e f | P r o j e c t | E n u m | T a b l e G r o u p ) \b / i;
431+ // 把 TablePartial / DiagramView / records / Note 也纳入识别:
432+ // 否则 findNextKeyword 会逐字符地走过它们的 body 内容(包含 jsonb 字面量、
433+ // 反引号表达式、Markdown 文本等),有概率撞上像 `TableGroup` 这样的字眼并误识别。
434+ // 这里识别后仍然分类为 'unknown' 在主循环里跳过。
435+ // 顺序:长前缀放在前面(TablePartial 在 Table 前,TableGroup 在 Table 前),
436+ // 否则 `Table\b` 会优先返回但匹配失败浪费一次。
437+ const TOP_KEYWORD_RE =
438+ / ^ ( T a b l e P a r t i a l | T a b l e G r o u p | T a b l e | R e f | P r o j e c t | E n u m | D i a g r a m V i e w | r e c o r d s | N o t e ) \b / i;
396439
397440// 从 from 开始找下一处可能开启顶层语句的关键字位置(必须在词边界,
398441// 且当前不在字符串字面量里)。找不到返回 -1。
@@ -426,20 +469,40 @@ const tokenizeTopLevel = (src: string): TopStatement[] => {
426469
427470 let braceIdx = - 1 ;
428471 let lineEndIdx = - 1 ;
472+ let bracketDepth = 0 ;
473+ let seenOperator = false ;
429474 let j = i ;
430475 while ( j < n ) {
431476 const ch = src [ j ] ;
432477 if ( ch === "'" || ch === '"' || ch === "`" ) {
433478 j = skipString ( src , j ) ;
434479 continue ;
435480 }
436- if ( ch === "{" ) {
481+ if ( ch === "[" ) {
482+ bracketDepth ++ ;
483+ j ++ ;
484+ continue ;
485+ }
486+ if ( ch === "]" ) {
487+ if ( bracketDepth > 0 ) bracketDepth -- ;
488+ j ++ ;
489+ continue ;
490+ }
491+ if ( ch === "{" && bracketDepth === 0 ) {
437492 braceIdx = j ;
438493 break ;
439494 }
440- if ( ch === "\n" ) {
495+ // Ref 短句的关系运算符(仅在顶层、未在 [...] 设置块里时计数)。
496+ // 多行 `Ref name:\n a > b [...]` 形式时:`:` 后面的换行不能立刻
497+ // 终止语句 —— 至少要等到运算符出现,才认为左 / 右目标都已就位。
498+ if (
499+ bracketDepth === 0 &&
500+ ( ch === "<" || ch === ">" || ch === "-" )
501+ ) {
502+ seenOperator = true ;
503+ }
504+ if ( ch === "\n" && bracketDepth === 0 && seenOperator ) {
441505 const head = src . slice ( startIdx , j ) . trim ( ) ;
442- // Ref 短语句没有块;遇到换行就到此为止
443506 if ( / ^ R e f \b [ ^ { ] * : / i. test ( head ) ) {
444507 lineEndIdx = j ;
445508 break ;
@@ -485,7 +548,7 @@ const parseTableHeader = (
485548 }
486549 const m = h . match (
487550 new RegExp (
488- String . raw `^Table\s+(${ IDENT } )(?:\s+as\s+(${ IDENT } ))?\s*$` ,
551+ String . raw `^Table\s+(${ QUALIFIED_IDENT } )(?:\s+as\s+(${ IDENT } ))?\s*$` ,
489552 "i" ,
490553 ) ,
491554 ) ;
@@ -496,15 +559,39 @@ const parseTableHeader = (
496559 } ;
497560} ;
498561
562+ // DBML 关系运算符 → 两端基数。
563+ // `>` many-to-one (默认 FK 方向)
564+ // `<` one-to-many
565+ // `-` one-to-one
566+ // `<>` many-to-many
567+ const opToCardinality = (
568+ op : string ,
569+ ) : { from : import ( "../types" ) . Cardinality ; to : import ( "../types" ) . Cardinality } => {
570+ switch ( op ) {
571+ case "<" :
572+ return { from : "1" , to : "N" } ;
573+ case "-" :
574+ return { from : "1" , to : "1" } ;
575+ case "<>" :
576+ return { from : "N" , to : "N" } ;
577+ case ">" :
578+ default :
579+ return { from : "N" , to : "1" } ;
580+ }
581+ } ;
582+
499583const addRelationship = (
500584 ref : ParsedRefStatement ,
501585 relationships : ParsedRelationship [ ] ,
502586 tableByName : Map < string , ParsedTable > ,
503587) : void => {
588+ const card = opToCardinality ( ref . op ) ;
504589 relationships . push ( {
505590 from : ref . from . table ,
506591 to : ref . to . table ,
507592 label : ref . from . column ,
593+ fromCardinality : card . from ,
594+ toCardinality : card . to ,
508595 } ) ;
509596 const t = tableByName . get ( ref . from . table ) ;
510597 if ( t ) {
@@ -535,9 +622,18 @@ export const parseDBML = (dbml: string): ParseResult => {
535622 for ( const line of splitLogicalLines ( stmt . body ) ) {
536623 const trimmed = line . trim ( ) ;
537624 if ( ! trimmed ) continue ;
538- // 跳过嵌套块:Note { ... } / Note: '...' / indexes { ... }
625+ // 跳过嵌套块 / 非列声明:
626+ // Note { ... } / Note: '...'
627+ // indexes { ... }
628+ // checks { ... } DBML 校验约束块
629+ // records { ... } / records (cols) { ... } 插桩示例数据块
630+ // ~partial_name TablePartial 注入;不展开,仅跳过
631+ // 不跳过会被 parseColumnLine 错认为以 `checks` / `records` 命名的列。
539632 if ( / ^ N o t e \s * [: { ] / i. test ( trimmed ) ) continue ;
540633 if ( / ^ i n d e x e s \s * \{ / i. test ( trimmed ) ) continue ;
634+ if ( / ^ c h e c k s \s * \{ / i. test ( trimmed ) ) continue ;
635+ if ( / ^ r e c o r d s \b / i. test ( trimmed ) ) continue ;
636+ if ( trimmed . startsWith ( "~" ) ) continue ;
541637
542638 const { column, inlineRef } = parseColumnLine ( trimmed ) ;
543639 if ( ! column ) continue ;
@@ -548,10 +644,13 @@ export const parseDBML = (dbml: string): ParseResult => {
548644 referencedTable : inlineRef . target . table ,
549645 referencedColumn : inlineRef . target . column ,
550646 } ) ;
647+ const card = opToCardinality ( inlineRef . op ) ;
551648 relationships . push ( {
552649 from : head . name ,
553650 to : inlineRef . target . table ,
554651 label : column . name ,
652+ fromCardinality : card . from ,
653+ toCardinality : card . to ,
555654 } ) ;
556655 }
557656 columns . push ( column ) ;
@@ -587,5 +686,28 @@ export const parseDBML = (dbml: string): ParseResult => {
587686 // enum / project / tablegroup / unknown:忽略
588687 }
589688
689+ // 基数推断:当关系是默认的 N:1(来自 `>` 或缺省),但 FK 列在 from 表上
690+ // 是单列主键或带 unique 约束时,把 from 端升级为 "1" —— 这才是 1:1。
691+ // 例:
692+ // Table user_profiles { user_id bigint [pk, ref: > users.id] } // 推断 1:1
693+ // Table payments { order_id bigint [unique]; ... }
694+ // Ref: payments.order_id > orders.id // 推断 1:1
695+ // 对显式写 `<` / `-` / `<>` 的关系不做改动,尊重作者意图。
696+ // label 含逗号说明是复合 FK,单列推断不适用,跳过。
697+ for ( const rel of relationships ) {
698+ if ( rel . fromCardinality !== "N" || rel . toCardinality !== "1" ) continue ;
699+ if ( rel . label . includes ( "," ) ) continue ;
700+ const fromTable = tableByName . get ( rel . from ) ;
701+ if ( ! fromTable ) continue ;
702+ const col = fromTable . columns . find ( ( c ) => c . name === rel . label ) ;
703+ if ( ! col ) continue ;
704+ const isOnlySinglePk =
705+ fromTable . primaryKeys . length === 1 &&
706+ fromTable . primaryKeys [ 0 ] === col . name ;
707+ if ( col . isUnique || isOnlySinglePk ) {
708+ rel . fromCardinality = "1" ;
709+ }
710+ }
711+
590712 return { tables, relationships } ;
591713} ;
0 commit comments