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/__init__.py +1 -0
- quickdbd_cli/__main__.py +5 -0
- quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- quickdbd_cli/assets/Inter-LICENSE.txt +92 -0
- quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- quickdbd_cli/assets/editor.js +244 -0
- quickdbd_cli/assets/elk-LICENSE.md +263 -0
- quickdbd_cli/assets/elk.bundled.js +6605 -0
- quickdbd_cli/assets/font-metrics.json +1 -0
- quickdbd_cli/assets/render.js +230 -0
- quickdbd_cli/assets/style.css +83 -0
- quickdbd_cli/assets/viewer.js +337 -0
- quickdbd_cli/cli.py +169 -0
- quickdbd_cli/export.py +79 -0
- quickdbd_cli/html.py +97 -0
- quickdbd_cli/jsrt.py +77 -0
- quickdbd_cli/layout.py +207 -0
- quickdbd_cli/measure.py +111 -0
- quickdbd_cli/model.py +107 -0
- quickdbd_cli/parser.py +717 -0
- quickdbd_cli/project.py +69 -0
- quickdbd_cli/serve.py +210 -0
- quickdbd_cli-0.1.0.dist-info/METADATA +153 -0
- quickdbd_cli-0.1.0.dist-info/RECORD +32 -0
- quickdbd_cli-0.1.0.dist-info/WHEEL +4 -0
- quickdbd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- quickdbd_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- quickdbd_cli-0.1.0.dist-info/licenses/NOTICE +19 -0
- quickdbd_cli-0.1.0.dist-info/licenses/src/quickdbd_cli/assets/Inter-LICENSE.txt +92 -0
- quickdbd_cli-0.1.0.dist-info/licenses/src/quickdbd_cli/assets/elk-LICENSE.md +263 -0
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)])
|