quickdbd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
quickdbd_cli/parser.py ADDED
@@ -0,0 +1,717 @@
1
+ """QuickDBD schema parser.
2
+
3
+ This mirrors the behaviour of QuickDBD's own parser (app bundle v2.4.427.0) step by step,
4
+ including its error messages, so that any schema accepted by QuickDBD is accepted here and
5
+ produces the same tables, columns and relationships.
6
+
7
+ Deliberate deviations ("forgiving" mode) are accepted with a warning instead of an error, and
8
+ only where the input is unambiguous: tab characters, indented table headers/separators, spaces
9
+ inside a type's parentheses, an explicit ``NOT NULL``, and a ``[layout]`` line naming an
10
+ unknown table. ``default=`` values keep their original case (QuickDBD lowercases them).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+
18
+ from .model import REL_TYPES, VIRTUAL_FIELD, Column, Diagnostic, End, Relationship, Schema, Table
19
+
20
+ SECTIONS = ["tables", "settings", "layout"]
21
+ SECTION_ALIASES = {"rels": "relationships"}
22
+ ALLOWED_REL = ", ".join(REL_TYPES)
23
+ REJECT = "QuickDBD would reject this: "
24
+
25
+
26
+ # --------------------------------------------------------------------------- helpers
27
+
28
+
29
+ def _allowed_char(ch: str) -> bool:
30
+ return bool(re.match(r"[a-zA-Z0-9_]", ch))
31
+
32
+
33
+ def encode(text: str) -> str:
34
+ """QuickDBD's identifier encoding: non [A-Za-z0-9_] characters become their char code."""
35
+ return "".join(ch if _allowed_char(ch) else str(ord(ch)) for ch in text)
36
+
37
+
38
+ def _is_same_chars(value: str, ch: str) -> bool:
39
+ return value.startswith(ch) and (value == ch or re.fullmatch(r"(.)\1+", value, re.S) is not None)
40
+
41
+
42
+ def is_separator(value: str) -> bool:
43
+ return _is_same_chars(value, "-")
44
+
45
+
46
+ def is_virtual(name: str) -> bool:
47
+ return _is_same_chars(name, ".")
48
+
49
+
50
+ def is_rel_type(token: str | None) -> bool:
51
+ return token is not None and token.strip() in REL_TYPES
52
+
53
+
54
+ def _split_ws(text: str) -> list[str]:
55
+ return [t for t in text.split(" ") if t] if text else []
56
+
57
+
58
+ def _is_numeric(text: str) -> bool:
59
+ try:
60
+ v = float(text.strip())
61
+ except (ValueError, AttributeError):
62
+ return False
63
+ return v == v and v not in (float("inf"), float("-inf"))
64
+
65
+
66
+ _LEADING_FLOAT = re.compile(r"^\s*[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?")
67
+
68
+
69
+ def _parse_float(text: str) -> float | None:
70
+ m = _LEADING_FLOAT.match(text)
71
+ return float(m.group(0)) if m else None
72
+
73
+
74
+ def _split_first(text: str, delim: str) -> list[str]:
75
+ i = text.find(delim)
76
+ if i == -1:
77
+ return [text]
78
+ return [text[:i], text[i + 1 :]]
79
+
80
+
81
+ def line_data(raw: str) -> tuple[str, str | None]:
82
+ """Split a raw line into (data, comment). ``#`` inside double quotes is not a comment."""
83
+ if "#" not in raw:
84
+ return raw.rstrip(), None
85
+ parts = raw.split("#")
86
+ found = False
87
+ data = ""
88
+ comment = ""
89
+ for part in parts:
90
+ if found:
91
+ comment = part
92
+ break
93
+ joined = (data + "#" if data else "") + part
94
+ data = joined
95
+ if joined.count('"') % 2 == 0:
96
+ found = True
97
+ return data.rstrip(), comment.strip()
98
+
99
+
100
+ @dataclass
101
+ class Token:
102
+ value: str
103
+ encoded: str | None = None
104
+
105
+
106
+ def tokenize_field_line(text: str) -> list[Token]:
107
+ if '"' not in text:
108
+ return [Token(t) for t in _split_ws(text)]
109
+ state = 0 if text.startswith('"') else 1
110
+ tokens: list[Token] = []
111
+ raw: list[str] = []
112
+ enc: list[str] = []
113
+
114
+ def push() -> None:
115
+ v, e = "".join(raw), "".join(enc)
116
+ tokens.append(Token(v, e if e != v else None))
117
+
118
+ for ch in text[1 if state == 0 else 0 :]:
119
+ if state == 0:
120
+ if ch == '"':
121
+ state = 1
122
+ else:
123
+ raw.append(ch)
124
+ enc.append(ch)
125
+ elif state == 1:
126
+ if ch == " ":
127
+ if raw:
128
+ push()
129
+ raw, enc = [], []
130
+ elif ch == '"':
131
+ state = 2
132
+ else:
133
+ raw.append(ch)
134
+ enc.append(ch)
135
+ else:
136
+ if ch == '"':
137
+ state = 1
138
+ else:
139
+ raw.append(ch)
140
+ enc.append(ch if _allowed_char(ch) else str(ord(ch)))
141
+ if raw:
142
+ push()
143
+ return tokens
144
+
145
+
146
+ def tokenize_layout_line(text: str) -> list[str]:
147
+ if '"' not in text:
148
+ return _split_ws(text)
149
+ state = 0 if text.startswith('"') else 1
150
+ out: list[str] = []
151
+ cur: list[str] = []
152
+ for ch in text[1 if state == 0 else 0 :]:
153
+ if state == 0:
154
+ if ch == '"':
155
+ state = 1
156
+ else:
157
+ cur.append(ch)
158
+ else:
159
+ if ch == " ":
160
+ if cur:
161
+ out.append("".join(cur))
162
+ cur = []
163
+ else:
164
+ cur.append(ch)
165
+ if cur:
166
+ out.append("".join(cur))
167
+ return out
168
+
169
+
170
+ # --------------------------------------------------------------------------- per-file parsing
171
+
172
+
173
+ @dataclass
174
+ class Entry:
175
+ line: int
176
+ value: str
177
+ comments: list[str]
178
+
179
+
180
+ @dataclass
181
+ class PendingRel:
182
+ member1: str
183
+ rel_type: str
184
+ member2: str
185
+ path: str
186
+ line: int
187
+ case_sensitive: bool
188
+
189
+
190
+ @dataclass
191
+ class FileParse:
192
+ path: str
193
+ tables: dict[str, Table] = field(default_factory=dict)
194
+ aliases: dict[str, str] = field(default_factory=dict)
195
+ rels: list[PendingRel] = field(default_factory=list)
196
+ settings: dict = field(default_factory=dict)
197
+ layout_entries: list[Entry] = field(default_factory=list)
198
+ diagnostics: list[Diagnostic] = field(default_factory=list)
199
+ general_comments: list[str] = field(default_factory=list)
200
+
201
+ def error(self, line: int, msg: str) -> None:
202
+ self.diagnostics.append(Diagnostic(self.path, line, msg, "error"))
203
+
204
+ def warn(self, line: int, msg: str) -> None:
205
+ self.diagnostics.append(Diagnostic(self.path, line, REJECT + msg, "warning"))
206
+
207
+
208
+ class _FileParser:
209
+ def __init__(self, text: str, path: str):
210
+ self.text = text
211
+ self.fp = FileParse(path)
212
+
213
+ # -- sectioning ---------------------------------------------------------
214
+ def tokenize(self) -> dict[str, list[Entry]]:
215
+ fp = self.fp
216
+ out: dict[str, list[Entry]] = {"settings": [], "tables": [], "layout": []}
217
+ pending: list[str] = []
218
+ section = "tables"
219
+ seen_sections: list[str] = []
220
+ seen_data = False
221
+ for idx, raw in enumerate(re.split(r"\r?\n", self.text)):
222
+ lineno = idx + 1
223
+ if "\t" in raw:
224
+ data_part, _ = line_data(raw)
225
+ if "\t" in data_part:
226
+ fp.warn(lineno, "tab character found (QuickDBD expects spaces)")
227
+ raw = raw.replace("\t", " ")
228
+ blank = raw.rstrip() == ""
229
+ data, comment = line_data(raw)
230
+ if comment:
231
+ pending.append(comment)
232
+ elif blank and not seen_data:
233
+ fp.general_comments.extend(pending)
234
+ pending = []
235
+ if data:
236
+ seen_data = True
237
+ if (blank and section != "tables") or (not blank and not data):
238
+ continue
239
+ entry = Entry(lineno, data, pending if (seen_data and data) else [])
240
+ if seen_data and data:
241
+ pending = []
242
+ if data.startswith("[") and data.endswith("]"):
243
+ name = data.rstrip("]").lstrip("[").lower()
244
+ name = SECTION_ALIASES.get(name, name)
245
+ if not self._validate_section(name, seen_sections, lineno):
246
+ continue
247
+ seen_sections.append(name)
248
+ section = name
249
+ else:
250
+ out[section].append(entry)
251
+ return out
252
+
253
+ def _validate_section(self, name: str, seen: list[str], line: int) -> bool:
254
+ if name not in SECTIONS:
255
+ self.fp.error(line, f"Invalid section name '[{name}]' (allowed: {', '.join(SECTIONS)})")
256
+ return False
257
+ if name in seen:
258
+ self.fp.error(line, f"Section '[{name}]' is already defined")
259
+ return False
260
+ before = SECTIONS[: SECTIONS.index(name)]
261
+ later = [s for s in seen if s not in before]
262
+ if later:
263
+ self.fp.error(
264
+ line, f"Section '[{name}]' is out of order. It must be defined before: '[{', '.join(later)}]'"
265
+ )
266
+ return False
267
+ return True
268
+
269
+ # -- settings -----------------------------------------------------------
270
+ def parse_settings(self, entries: list[Entry]) -> None:
271
+ fp = self.fp
272
+ s = fp.settings
273
+ for e in entries:
274
+ parts = _split_first(e.value, " ")
275
+ key = parts[0].strip()
276
+ k = key.lower()
277
+ val = parts[1].strip() if len(parts) > 1 else ""
278
+ if not k:
279
+ fp.error(e.line, "Setting key missing")
280
+ elif " " in k:
281
+ fp.error(e.line, "Setting key cannot contain whitespace")
282
+ elif not val:
283
+ fp.error(e.line, "Setting value missing")
284
+ elif k in s:
285
+ fp.error(e.line, f"Setting '{key}' already defined")
286
+ else:
287
+ parsed: object = val
288
+ if k in ("casesensitive", "casesensitivity"):
289
+ k = "casesensitive"
290
+ if val.lower() == "off":
291
+ parsed = False
292
+ elif val.lower() == "on":
293
+ parsed = True
294
+ else:
295
+ parsed = None
296
+ fp.error(e.line, "Invalid 'CaseSensitive' value (allowed: 'on', 'off')")
297
+ elif k == "position":
298
+ parsed = self._parse_position(val, e.line)
299
+ elif k == "zoom":
300
+ z = _parse_float(val)
301
+ if z is None or z < 10 or z > 200:
302
+ fp.error(e.line, "Invalid 'Zoom' value (allowed: number between 10 and 200)")
303
+ parsed = z
304
+ s[k] = parsed
305
+ s.setdefault("_lines", {})[k] = e.line
306
+ if not isinstance(s.get("casesensitive"), bool):
307
+ s["casesensitive"] = False
308
+
309
+ def _parse_position(self, val: str, line: int):
310
+ parts = _split_first(val, " ")
311
+ if len(parts) != 2:
312
+ self.fp.error(line, "Position setting requires 2 values (x, y)")
313
+ return None
314
+ x, y = parts[0].strip(), parts[1].strip()
315
+ if not _is_numeric(x):
316
+ self.fp.error(line, f"Invalid X coordinate '{x or 'undefined'}', number expected")
317
+ return None
318
+ if not _is_numeric(y):
319
+ self.fp.error(line, f"Invalid Y coordinate '{y or 'undefined'}', number expected")
320
+ return None
321
+ return (float(int(float(x))), float(int(float(y))))
322
+
323
+ # -- tables -------------------------------------------------------------
324
+ def parse_tables(self, entries: list[Entry]) -> None:
325
+ groups: list[list[Entry]] = []
326
+ cur: list[Entry] = []
327
+ for e in entries:
328
+ if e.value == "":
329
+ if cur:
330
+ groups.append(cur)
331
+ cur = []
332
+ else:
333
+ cur.append(e)
334
+ if cur:
335
+ groups.append(cur)
336
+ for g in groups:
337
+ self._parse_table(g)
338
+
339
+ def _parse_table(self, group: list[Entry]) -> None:
340
+ fp = self.fp
341
+ table_id: str | None = None
342
+ after_sep = False
343
+ for idx, e in enumerate(group):
344
+ value = e.value
345
+ if value != value.lstrip() and (idx == 0 or is_separator(value.strip())):
346
+ fp.warn(e.line, "indented table name or separator")
347
+ value = value.strip()
348
+ if is_separator(value):
349
+ if idx == 0:
350
+ fp.error(e.line, "Table name not defined")
351
+ after_sep = True
352
+ continue
353
+ if idx == 0:
354
+ table_id = self._parse_table_name(Entry(e.line, value, e.comments))
355
+ elif table_id:
356
+ if after_sep:
357
+ self._set_field(table_id, self._parse_field(table_id, e), e.line)
358
+ else:
359
+ self._parse_metadata(table_id, e)
360
+
361
+ def _parse_table_name(self, e: Entry) -> str | None:
362
+ fp = self.fp
363
+ parts = e.value.split(" as ")
364
+ name = parts[0]
365
+ if '"' in name or (" " not in name and "." not in name):
366
+ aliases = parts[1].replace(" ", "").split(",") if len(parts) > 1 else []
367
+ caption = name.strip('"')
368
+ ident = encode(caption)
369
+ if ident in fp.tables:
370
+ fp.error(e.line, f"Table '{caption}' already defined")
371
+ return None
372
+ fp.tables[ident] = Table(ident, caption, fp.path, e.line, aliases, {}, list(e.comments))
373
+ for a in aliases:
374
+ fp.aliases[a] = ident
375
+ return ident
376
+ fp.error(e.line, "Invalid table name (use double quotes)")
377
+ return None
378
+
379
+ def _parse_metadata(self, table_id: str, e: Entry) -> None:
380
+ if e.value.startswith("rel"):
381
+ parts = _split_ws(e.value)
382
+ if len(parts) > 3:
383
+ self.fp.error(e.line, f"Unknown relationship param '{parts[3]}'")
384
+ return
385
+ rel_type = parts[1] if len(parts) == 3 else "-"
386
+ target = parts[2] if len(parts) == 3 else (parts[1] if len(parts) > 1 else "")
387
+ self._queue(table_id, rel_type, target, e.line)
388
+ else:
389
+ self.fp.error(e.line, f"Unrecognized table metadata '{e.value}'")
390
+
391
+ def _queue(self, m1: str, rel_type: str, m2: str, line: int) -> None:
392
+ self.fp.rels.append(PendingRel(m1, rel_type, m2, self.fp.path, line, False))
393
+
394
+ def _merge_paren_tokens(self, tokens: list[Token], line: int) -> list[Token]:
395
+ """Forgiving: `decimal(10, 2)` -> one token `decimal(10,2)`."""
396
+ out: list[Token] = []
397
+ i = 0
398
+ while i < len(tokens):
399
+ t = tokens[i]
400
+ depth = t.value.count("(") - t.value.count(")")
401
+ if depth > 0 and t.encoded is None and i + 1 < len(tokens):
402
+ j = i
403
+ value = t.value
404
+ while depth > 0 and j + 1 < len(tokens) and tokens[j + 1].encoded is None:
405
+ j += 1
406
+ value += tokens[j].value
407
+ depth += tokens[j].value.count("(") - tokens[j].value.count(")")
408
+ if depth == 0 and j > i:
409
+ self.fp.warn(line, f"spaces inside '{' '.join(x.value for x in tokens[i : j + 1])}'")
410
+ out.append(Token(value))
411
+ i = j + 1
412
+ continue
413
+ out.append(t)
414
+ i += 1
415
+ return out
416
+
417
+ def _parse_field(self, table_id: str, e: Entry) -> Column | None:
418
+ fp = self.fp
419
+ tokens = tokenize_field_line(e.value)
420
+ if not tokens:
421
+ fp.error(e.line, "Table field name cannot be empty")
422
+ return None
423
+ name = tokens[0].value.strip()
424
+ ident = encode(name)
425
+ if not ident:
426
+ fp.error(e.line, "Table field name cannot be empty")
427
+ return None
428
+ if is_virtual(ident):
429
+ return Column(VIRTUAL_FIELD, "", e.line, virtual=True)
430
+ tokens = [tokens[0]] + self._merge_paren_tokens(tokens[1:], e.line)
431
+ col = Column(ident, name, e.line, comments=list(e.comments))
432
+ fk_at = -1
433
+ d = 0
434
+ while d + 1 < len(tokens):
435
+ d += 1
436
+ c = tokens[d]
437
+ low = c.value.strip()
438
+ if fk_at > -1:
439
+ if d == fk_at + 1:
440
+ if is_rel_type(c.value):
441
+ if d == len(tokens) - 1:
442
+ fp.error(e.line, "Relationship key cannot be empty")
443
+ continue
444
+ fk_at = -1
445
+ elif d == fk_at + 2:
446
+ self._queue(f"{table_id}.{ident}", tokens[d - 1].value or "-", c.encoded or c.value or "", e.line)
447
+ fk_at = -1
448
+ continue
449
+ h = low.lower()
450
+ if h == "not" and d + 1 < len(tokens) and tokens[d + 1].value.strip().lower() == "null":
451
+ fp.warn(e.line, "explicit 'NOT NULL' (columns are NOT NULL by default)")
452
+ d += 1
453
+ continue
454
+ if h in ("null", "nullable"):
455
+ col.nullable = True
456
+ elif h == "pk":
457
+ if col.unique:
458
+ fp.error(
459
+ e.line, "The field cannot be a primary key and an unique key, since a primary key is unique"
460
+ )
461
+ if col.index:
462
+ fp.error(e.line, "The field cannot be a primary key and an index, since a primary key is an index")
463
+ col.pk = True
464
+ elif h == "unique":
465
+ if col.pk:
466
+ fp.error(
467
+ e.line, "The field cannot be a primary key and an unique key, since a primary key is unique"
468
+ )
469
+ if col.index:
470
+ fp.error(e.line, "The field cannot be an unique key and an index, since an unique key is an index")
471
+ col.unique = True
472
+ elif h == "index":
473
+ if col.pk:
474
+ fp.error(e.line, "The field cannot be a primary key and an index, since a primary key is an index")
475
+ if col.unique:
476
+ fp.error(e.line, "The field cannot be an unique key and an index, since an unique key is an index")
477
+ col.index = True
478
+ elif h == "fk":
479
+ col.fk = True
480
+ fk_at = d
481
+ elif h in ("identity", "autoincrement"):
482
+ col.identity = True
483
+ elif h.startswith("default="):
484
+ col.default = low[len("default=") :]
485
+ elif col.type is not None:
486
+ fp.error(e.line, f"Unrecognized keyword '{low}'")
487
+ elif "=" in low:
488
+ parts = low.split("=")
489
+ col.type, col.default = parts[0], parts[1]
490
+ else:
491
+ col.type = low
492
+ return col
493
+
494
+ def _set_field(self, table_id: str, col: Column | None, line: int) -> None:
495
+ if col is None:
496
+ return
497
+ table = self.fp.tables[table_id]
498
+ if col.id in table.columns:
499
+ self.fp.error(line, f"Table field '{col.id}' already defined")
500
+ return
501
+ col.order = len(table.columns)
502
+ table.columns[col.id] = col
503
+
504
+ def run(self) -> FileParse:
505
+ sections = self.tokenize()
506
+ self.parse_settings(sections["settings"])
507
+ cs = self.fp.settings["casesensitive"]
508
+ default_type = self.fp.settings.get("defaultfieldtype")
509
+ self.parse_tables(sections["tables"])
510
+ for r in self.fp.rels:
511
+ r.case_sensitive = cs
512
+ if default_type:
513
+ for t in self.fp.tables.values():
514
+ for c in t.columns.values():
515
+ if c.type is None and not c.virtual:
516
+ c.type = default_type
517
+ self.fp.layout_entries = sections["layout"]
518
+ return self.fp
519
+
520
+
521
+ def parse_file(text: str, path: str = "<input>") -> FileParse:
522
+ return _FileParser(text, path).run()
523
+
524
+
525
+ # --------------------------------------------------------------------------- whole-schema compile
526
+
527
+
528
+ class _Resolver:
529
+ def __init__(self, files: list[FileParse]):
530
+ self.files = files
531
+ self.schema = Schema()
532
+ self.aliases: dict[str, str] = {}
533
+ self._diag: dict[str, list[Diagnostic]] = {f.path: f.diagnostics for f in files}
534
+
535
+ def error(self, path: str, line: int, msg: str, severity: str = "error") -> None:
536
+ self._diag.setdefault(path, []).append(Diagnostic(path, line, msg, severity))
537
+
538
+ def get_table(self, ident: str, cs: bool) -> Table | None:
539
+ if cs:
540
+ return self.schema.tables.get(ident)
541
+ low = ident.lower()
542
+ for t in self.schema.tables.values():
543
+ if t.id.lower() == low:
544
+ return t
545
+ return None
546
+
547
+ @staticmethod
548
+ def get_column(table: Table, ident: str, cs: bool) -> Column | None:
549
+ if cs:
550
+ return table.columns.get(ident)
551
+ low = ident.lower()
552
+ for c in table.columns.values():
553
+ if c.id.lower() == low:
554
+ return c
555
+ return None
556
+
557
+ def merge(self) -> None:
558
+ s = self.schema
559
+ for f in self.files:
560
+ for ident, table in f.tables.items():
561
+ if ident in s.tables:
562
+ first = s.tables[ident]
563
+ self.error(
564
+ f.path,
565
+ table.line,
566
+ f"Table '{table.name}' already defined (first defined at {first.path}:{first.line})",
567
+ )
568
+ continue
569
+ s.tables[ident] = table
570
+ self.aliases.update(f.aliases)
571
+ s.general_comments.extend(f.general_comments)
572
+ zoom = f.settings.get("zoom")
573
+ pos = f.settings.get("position")
574
+ lines = f.settings.get("_lines", {})
575
+ if zoom is not None:
576
+ if s.zoom is None:
577
+ s.zoom = zoom
578
+ elif s.zoom != zoom:
579
+ self.error(f.path, lines["zoom"], "Zoom differs from an earlier file; using the first", "warning")
580
+ if pos is not None:
581
+ if s.position is None:
582
+ s.position = pos
583
+ elif s.position != pos:
584
+ self.error(
585
+ f.path, lines["position"], "Position differs from an earlier file; using the first", "warning"
586
+ )
587
+
588
+ def parse_token(self, text: str, rel: PendingRel) -> End | None:
589
+ t = text.strip()
590
+ if not t:
591
+ self.error(rel.path, rel.line, "Relationship key cannot be empty")
592
+ return None
593
+ parts = t.split(".")
594
+ if len(parts) > 2:
595
+ self.error(rel.path, rel.line, "Multi-nested relationships are not allowed")
596
+ return None
597
+ table = self.get_table(parts[0], rel.case_sensitive)
598
+ col = self.get_column(table, parts[1], rel.case_sensitive) if (len(parts) == 2 and table) else None
599
+ table_id = table.id if table else parts[0]
600
+ col_id = (col.id if col else parts[1]) if len(parts) == 2 else VIRTUAL_FIELD
601
+ return End(table_id, col_id)
602
+
603
+ def resolve_rels(self) -> None:
604
+ s = self.schema
605
+ for rel in (r for f in self.files for r in f.rels):
606
+ if not is_rel_type(rel.rel_type):
607
+ self.error(rel.path, rel.line, f"Invalid relationship description (allowed: {ALLOWED_REL})")
608
+ continue
609
+ a = self.parse_token(rel.member1, rel)
610
+ b = self.parse_token(rel.member2, rel)
611
+ if not (a and b):
612
+ continue
613
+ for end in (a, b):
614
+ if end.table in self.aliases:
615
+ end.table = self.aliases[end.table]
616
+ t = self.get_table(end.table, rel.case_sensitive)
617
+ if t:
618
+ end.table = t.id
619
+ a.card, b.card = REL_TYPES[rel.rel_type.strip()]
620
+ dup = next((r for r in s.relationships if _same_rel(r, a, b)), None)
621
+ if dup:
622
+ where = f"{dup.line}" if dup.path == rel.path else f"{dup.path}:{dup.line}"
623
+ self.error(rel.path, rel.line, f"Relationship already defined at line {where}")
624
+ continue
625
+ r = Relationship(a, b, rel.path, rel.line)
626
+ s.relationships.append(r)
627
+ self.post_process(r, rel.case_sensitive)
628
+
629
+ def post_process(self, r: Relationship, cs: bool) -> None:
630
+ tables = []
631
+ for end in (r.a, r.b):
632
+ t = self.get_table(end.table, cs)
633
+ if t is None:
634
+ self.error(r.path, r.line, f"Invalid relationship, the table '{end.table}' does not exist")
635
+ tables.append(t)
636
+ if None in tables:
637
+ return
638
+ cols = []
639
+ for end, t in zip((r.a, r.b), tables, strict=True):
640
+ if not end.column:
641
+ self.error(r.path, r.line, f"Invalid relationship, the field '{end.table}.' is missing.")
642
+ cols.append(None)
643
+ continue
644
+ c = self.get_column(t, end.column, cs)
645
+ if c is None:
646
+ if end.column != VIRTUAL_FIELD:
647
+ self.error(
648
+ r.path, r.line, f"Invalid relationship, the field '{end.table}.{end.column}' does not exist"
649
+ )
650
+ cols.append(None)
651
+ continue
652
+ c = Column(VIRTUAL_FIELD, "", r.line, order=len(t.columns), virtual=True)
653
+ t.columns[VIRTUAL_FIELD] = c
654
+ end.column = c.id
655
+ cols.append(c)
656
+ for c in cols:
657
+ if c is not None and not c.pk and not c.virtual:
658
+ c.fk = True
659
+
660
+ def parse_layout(self) -> None:
661
+ s = self.schema
662
+ for f in self.files:
663
+ cs = f.settings["casesensitive"]
664
+ for e in f.layout_entries:
665
+ parts = tokenize_layout_line(e.value)
666
+ if len(parts) != 3:
667
+ self.error(f.path, e.line, "Layout definition requires 3 values (tableName, x, y)")
668
+ continue
669
+ name, xs, ys = parts
670
+ ident = encode(name)
671
+ if not ident:
672
+ self.error(f.path, e.line, "Table name cannot be empty")
673
+ continue
674
+ t = self.get_table(ident, cs)
675
+ if t is None:
676
+ self.error(
677
+ f.path,
678
+ e.line,
679
+ REJECT + f"Invalid layout definition name, the table '{name}' does not exist",
680
+ "warning",
681
+ )
682
+ continue
683
+ if t.id in s.layout:
684
+ self.error(f.path, e.line, f"Layout for '{name}' already defined")
685
+ continue
686
+ if not _is_numeric(xs):
687
+ self.error(f.path, e.line, f"Invalid X coordinate '{xs.strip()}', number expected")
688
+ continue
689
+ if not _is_numeric(ys):
690
+ self.error(f.path, e.line, f"Invalid Y coordinate '{ys.strip()}', number expected")
691
+ continue
692
+ s.layout[t.id] = (float(xs), float(ys))
693
+
694
+ def run(self) -> Schema:
695
+ self.merge()
696
+ self.resolve_rels()
697
+ self.parse_layout()
698
+ diags = [d for f in self.files for d in self._diag.get(f.path, [])]
699
+ order = {f.path: i for i, f in enumerate(self.files)}
700
+ diags.sort(key=lambda d: (order.get(d.path, 0), d.line))
701
+ self.schema.diagnostics = diags
702
+ return self.schema
703
+
704
+
705
+ def _same_rel(r: Relationship, a: End, b: End) -> bool:
706
+ return (r.a.table == a.table and r.a.column == a.column and r.b.table == b.table and r.b.column == b.column) or (
707
+ r.a.table == b.table and r.a.column == b.column and r.b.table == a.table and r.b.column == a.column
708
+ )
709
+
710
+
711
+ def compile_sources(sources: list[tuple[str, str]]) -> Schema:
712
+ """Compile ``[(path, text), ...]`` into one Schema (tables share one namespace)."""
713
+ return _Resolver([parse_file(text, path) for path, text in sources]).run()
714
+
715
+
716
+ def compile_text(text: str, path: str = "<input>") -> Schema:
717
+ return compile_sources([(path, text)])