baucli 1.0.1__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.
- baucli/__init__.py +8 -0
- baucli/apex_rebuild.py +1458 -0
- baucli/apexlang.py +317 -0
- baucli/api.py +914 -0
- baucli/cli.py +7754 -0
- baucli/config.py +244 -0
- baucli/db/__init__.py +117 -0
- baucli/db/mssql.py +443 -0
- baucli/db/mysql.py +388 -0
- baucli/db/oracle.py +1294 -0
- baucli/db/postgresql.py +482 -0
- baucli/docmig.py +149 -0
- baucli/git.py +214 -0
- baucli/legacy/__init__.py +9 -0
- baucli/legacy/delphi.py +365 -0
- baucli/mcp_server.py +657 -0
- baucli/project.py +360 -0
- baucli/rag_embed.py +26 -0
- baucli/sample_scan.py +105 -0
- baucli/scope_scan.py +272 -0
- baucli/screenshot.py +316 -0
- baucli/seed_check.py +190 -0
- baucli/stress.py +409 -0
- baucli/sync.py +846 -0
- baucli-1.0.1.dist-info/METADATA +179 -0
- baucli-1.0.1.dist-info/RECORD +29 -0
- baucli-1.0.1.dist-info/WHEEL +5 -0
- baucli-1.0.1.dist-info/entry_points.txt +2 -0
- baucli-1.0.1.dist-info/top_level.txt +1 -0
baucli/apex_rebuild.py
ADDED
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
"""APEX page rebuild — rewrite labels / alignment / format masks in .apx files
|
|
2
|
+
from BAU table metadata, and optionally push the rewritten page back into
|
|
3
|
+
the running APEX instance via `sql apex import`.
|
|
4
|
+
|
|
5
|
+
Scope (matches the agreement with the user):
|
|
6
|
+
- Page items inside form regions: `pageItem <NAME> ( ... )`
|
|
7
|
+
- IR/IG columns inside report regions: `column <NAME> ( ... )`
|
|
8
|
+
- savedReport `displayColumn <NAME> ( ... )` — sequence reordering only.
|
|
9
|
+
|
|
10
|
+
Attributes rewritten when a BAU match is found:
|
|
11
|
+
* `label: ...` on pageItem → BAU.LABEL
|
|
12
|
+
* `heading { heading: ... }` on column → BAU.LABEL
|
|
13
|
+
* `help { helpText: ... }` on pageItem → BAU.DESCRIPTION
|
|
14
|
+
(single-line collapsed; help is always the LAST nested block — the
|
|
15
|
+
dominant position across every sampled export)
|
|
16
|
+
* `settings { textCase: upper|lower }` → BAU.TEXT_CASE U/L
|
|
17
|
+
(textField blocks only; Initcap has no APEX equivalent and is skipped)
|
|
18
|
+
* `appearance { formatMask: ... }` → BAU.FORMAT_MASK (added if missing)
|
|
19
|
+
* `layout { columnAlignment: ... }` → BAU.ALIGN_REPORT or ALIGN_FORM
|
|
20
|
+
(mapped L/C/R → start/center/end; missing means "leave as-is")
|
|
21
|
+
* `type: hidden` when BAU.HIDDEN = Y — display-only properties
|
|
22
|
+
(formatMask, columnAlignment) are STRIPPED from blocks that are or
|
|
23
|
+
become hidden: the APEX 26 parser rejects them there (zero occurrences
|
|
24
|
+
inside `type: hidden` blocks across all sampled exports)
|
|
25
|
+
* `type: displayOnly` when BAU.READ_ONLY = Y and BAU.HIDDEN != Y (IR cols)
|
|
26
|
+
* `readOnly { type: always }` when BAU.READ_ONLY = Y (pageItem)
|
|
27
|
+
|
|
28
|
+
Design choices
|
|
29
|
+
==============
|
|
30
|
+
The .apx format isn't well-described publicly, but it is a deterministic
|
|
31
|
+
indented key/value grammar with balanced parentheses for blocks. A full
|
|
32
|
+
parser would be overkill for ~10 attributes; instead the module:
|
|
33
|
+
1. Loads the file as text.
|
|
34
|
+
2. Scans for the markers `pageItem <NAME> (`, `column <NAME> (`,
|
|
35
|
+
`displayColumn <NAME> (`.
|
|
36
|
+
3. For each, walks forward to the matching `)` (paren-depth aware,
|
|
37
|
+
string-literal aware) to delimit the block.
|
|
38
|
+
4. Within the block, applies idempotent text edits — read the existing
|
|
39
|
+
value, decide on new value, splice the new text back into the buffer.
|
|
40
|
+
|
|
41
|
+
The script never reorders blocks (would be too invasive without a proper
|
|
42
|
+
parser). For column ordering, only `sequence:` inside layout/displayColumn
|
|
43
|
+
is touched, leaving the textual block order alone.
|
|
44
|
+
|
|
45
|
+
Discovery
|
|
46
|
+
=========
|
|
47
|
+
The .apx file is found via the project convention:
|
|
48
|
+
<project-root>/apex/<workspace>/pages/p<padded-id>-*.apx
|
|
49
|
+
The function `find_apex_file` returns the unique match or raises with a
|
|
50
|
+
diagnostic message listing what it tried.
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import subprocess
|
|
57
|
+
from dataclasses import dataclass, field
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from typing import Iterable
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
# Data carrier for the BAU metadata
|
|
64
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
@dataclass
|
|
66
|
+
class BauColumn:
|
|
67
|
+
name: str
|
|
68
|
+
label: str | None = None
|
|
69
|
+
description: str | None = None
|
|
70
|
+
format_mask: str | None = None
|
|
71
|
+
align_report: str | None = None # 'L'|'C'|'R' or None
|
|
72
|
+
align_form: str | None = None
|
|
73
|
+
text_case: str | None = None
|
|
74
|
+
hidden: bool = False
|
|
75
|
+
read_only: bool = False
|
|
76
|
+
show_in_forms: bool = True
|
|
77
|
+
show_in_reports: bool = True
|
|
78
|
+
report_display_order: int | None = None
|
|
79
|
+
form_display_order: int | None = None
|
|
80
|
+
is_pk: bool = False
|
|
81
|
+
is_fk: bool = False
|
|
82
|
+
type: str | None = None
|
|
83
|
+
form_item_type: str | None = None # exact APEXLang token (checkbox, ...)
|
|
84
|
+
# UI default for the generated item: form_default (explicit/rule, used
|
|
85
|
+
# as-is) takes precedence over the raw table data_default (filtered to
|
|
86
|
+
# literals by _safe_default).
|
|
87
|
+
form_default: str | None = None
|
|
88
|
+
data_default: str | None = None
|
|
89
|
+
# CHECK-constraint domain as (return_value, display_label) pairs — used
|
|
90
|
+
# to synthesize a static LOV when FORM_ITEM_TYPE needs one.
|
|
91
|
+
domain_values: list = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_api(cls, d: dict) -> "BauColumn":
|
|
95
|
+
return cls(
|
|
96
|
+
name=d.get("name", "").upper(),
|
|
97
|
+
label=d.get("label"),
|
|
98
|
+
description=d.get("description"),
|
|
99
|
+
format_mask=d.get("format_mask"),
|
|
100
|
+
align_report=d.get("align_report"),
|
|
101
|
+
align_form=d.get("align_form"),
|
|
102
|
+
text_case=d.get("text_case"),
|
|
103
|
+
hidden=bool(d.get("hidden")),
|
|
104
|
+
read_only=bool(d.get("read_only")),
|
|
105
|
+
show_in_forms=d.get("show_in_forms") != False,
|
|
106
|
+
show_in_reports=d.get("show_in_reports") != False,
|
|
107
|
+
report_display_order=d.get("report_display_order"),
|
|
108
|
+
form_display_order=d.get("form_display_order"),
|
|
109
|
+
is_pk=bool(d.get("is_pk")),
|
|
110
|
+
is_fk=bool(d.get("is_fk")),
|
|
111
|
+
type=d.get("type"),
|
|
112
|
+
form_item_type=d.get("form_item_type"),
|
|
113
|
+
form_default=d.get("form_default"),
|
|
114
|
+
data_default=d.get("data_default"),
|
|
115
|
+
domain_values=[
|
|
116
|
+
(dv.get("value"), dv.get("label") or dv.get("value"))
|
|
117
|
+
for dv in (d.get("domain_values") or [])
|
|
118
|
+
if dv.get("value") is not None
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# IR/IG `column` blocks use `layout { columnAlignment: start|center|end }`.
|
|
124
|
+
ALIGN_MAP = {"L": "start", "C": "center", "R": "end"}
|
|
125
|
+
# `pageItem` blocks use `layout { alignment: left|center|right }` — a
|
|
126
|
+
# different key AND vocabulary (835 columnAlignment occurrences in the
|
|
127
|
+
# sampled exports, all in columns; pageItems only ever carry `alignment`).
|
|
128
|
+
ALIGN_MAP_ITEM = {"L": "left", "C": "center", "R": "right"}
|
|
129
|
+
|
|
130
|
+
# BAU TEXT_CASE → APEXLang `settings { textCase: ... }` token. 'I' (Initcap)
|
|
131
|
+
# has no APEX equivalent and is intentionally absent.
|
|
132
|
+
TEXT_CASE_MAP = {"U": "upper", "L": "lower"}
|
|
133
|
+
|
|
134
|
+
# Item types that legitimately carry an `lov { ... }` block. When
|
|
135
|
+
# FORM_ITEM_TYPE retypes an item away from these, the leftover lov block
|
|
136
|
+
# is invalid and must be stripped.
|
|
137
|
+
LOV_ITEM_TYPES = {"selectList", "radioGroup", "popupLov", "shuttle",
|
|
138
|
+
"checkboxGroup"}
|
|
139
|
+
|
|
140
|
+
# Item types where `appearance { height: N }` (number of rows) is valid.
|
|
141
|
+
# Every other type rejects it as INVALID_PROPERTY at import, so when a
|
|
142
|
+
# FORM_ITEM_TYPE retype moves an item away from these the leftover height
|
|
143
|
+
# must be stripped (e.g. a textField with height:1 retyped to radioGroup).
|
|
144
|
+
HEIGHT_ITEM_TYPES = {"textarea", "richTextEditor", "markdownEditor"}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
# Auto-discovery of tables referenced by a page
|
|
149
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
# Common SQL identifiers that are not table names but appear after FROM / JOIN
|
|
151
|
+
# (subquery aliases, set-keywords, system pseudo-tables, CTE markers).
|
|
152
|
+
_NOT_A_TABLE = {
|
|
153
|
+
"DUAL", "SELECT", "WHERE", "WITH", "ORDER", "GROUP",
|
|
154
|
+
"LATERAL", "TABLE", "VALUES", "UNION", "JOIN", "ON",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def discover_tables(source: str) -> list[str]:
|
|
159
|
+
"""Return the uppercase table names referenced by a page, in priority
|
|
160
|
+
order.
|
|
161
|
+
|
|
162
|
+
Looks at two signals:
|
|
163
|
+
|
|
164
|
+
1. `tableName: X` lines — APEXLang's explicit table reference in form
|
|
165
|
+
regions and table-mode IRs.
|
|
166
|
+
2. `FROM <schema>.<table>` and `JOIN <schema>.<table>` inside `sqlQuery`
|
|
167
|
+
blocks (triple-backtick fences). The schema prefix is stripped.
|
|
168
|
+
|
|
169
|
+
ORDER MATTERS: `tableName:`-sourced tables come first (file order), then
|
|
170
|
+
FROM/JOIN-sourced ones. The caller aggregates column metadata first-wins,
|
|
171
|
+
so on column-name collisions (ID, ACTIVE and audit columns exist in
|
|
172
|
+
EVERY BAU-convention table) the form's base table takes precedence over
|
|
173
|
+
lookup/join tables.
|
|
174
|
+
|
|
175
|
+
SQL identifiers that occur after FROM/JOIN but aren't tables (DUAL,
|
|
176
|
+
subquery aliases, etc.) are filtered out by name. Anything not known to
|
|
177
|
+
BAU just returns empty `columns` and contributes nothing — so the cost
|
|
178
|
+
of a false positive here is one extra REST call.
|
|
179
|
+
"""
|
|
180
|
+
ordered: list[str] = []
|
|
181
|
+
seen: set[str] = set()
|
|
182
|
+
|
|
183
|
+
def _add(name: str) -> None:
|
|
184
|
+
if name not in seen:
|
|
185
|
+
seen.add(name)
|
|
186
|
+
ordered.append(name)
|
|
187
|
+
|
|
188
|
+
for m in re.finditer(
|
|
189
|
+
r"^\s*tableName\s*:\s*([A-Za-z_][\w]*)", source, re.MULTILINE
|
|
190
|
+
):
|
|
191
|
+
_add(m.group(1).upper())
|
|
192
|
+
|
|
193
|
+
for m in re.finditer(
|
|
194
|
+
r"\b(?:from|join)\s+(?:[A-Za-z_][\w]*\.)?([A-Za-z_][\w]*)",
|
|
195
|
+
source, re.IGNORECASE,
|
|
196
|
+
):
|
|
197
|
+
name = m.group(1).upper()
|
|
198
|
+
if name in _NOT_A_TABLE:
|
|
199
|
+
continue
|
|
200
|
+
_add(name)
|
|
201
|
+
|
|
202
|
+
return ordered
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Simple [schema.]table.column / column reference (the "this column IS the
|
|
206
|
+
# stored value" case). Anything else aliased to a column name (scalar
|
|
207
|
+
# subquery, function call, CASE, concat …) is a DERIVED display value.
|
|
208
|
+
_BARE_COLREF = re.compile(
|
|
209
|
+
r'^"?[A-Za-z_][\w$#]*"?(?:\s*\.\s*"?[A-Za-z_][\w$#]*"?){0,2}$')
|
|
210
|
+
# Split a select item into (expression, output-alias). Alias = trailing
|
|
211
|
+
# identifier, with optional `AS`. Lazy head + `$` anchor force the alias to
|
|
212
|
+
# be the LAST token even past CASE/END.
|
|
213
|
+
_SELECT_ALIAS = re.compile(
|
|
214
|
+
r'^(.*?)\s+(?:as\s+)?"?([A-Za-z_][\w$#]*)"?$',
|
|
215
|
+
re.IGNORECASE | re.DOTALL)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def derived_display_cols(source: str) -> set[str]:
|
|
219
|
+
"""Return the UPPER output names of select-list projections that render a
|
|
220
|
+
DERIVED value (scalar subquery / function / expression) aliased to a
|
|
221
|
+
column name — e.g. `(select nome from … where id = t.SETOR_ID) SETOR_ID`.
|
|
222
|
+
|
|
223
|
+
Such columns display a STRING (the looked-up name), so the numeric-ID
|
|
224
|
+
metadata BAU holds for the underlying column (center align, 000000 mask)
|
|
225
|
+
must NOT be applied. Parsing is paren-aware so subquery commas/FROMs don't
|
|
226
|
+
break the split. Best-effort: on any oddity the column is simply omitted
|
|
227
|
+
(treated as non-derived), never raising."""
|
|
228
|
+
out: set[str] = set()
|
|
229
|
+
try:
|
|
230
|
+
for m in re.finditer(r"sqlQuery\s*:\s*```(?:sql)?\n(.*?)```",
|
|
231
|
+
source, re.DOTALL | re.IGNORECASE):
|
|
232
|
+
sql = m.group(1)
|
|
233
|
+
# Isolate the top-level select list: after the first `select`,
|
|
234
|
+
# up to the matching top-level `from` (depth 0).
|
|
235
|
+
sm = re.search(r"\bselect\b", sql, re.IGNORECASE)
|
|
236
|
+
if not sm:
|
|
237
|
+
continue
|
|
238
|
+
i, depth, start = sm.end(), 0, sm.end()
|
|
239
|
+
list_end = None
|
|
240
|
+
while i < len(sql):
|
|
241
|
+
ch = sql[i]
|
|
242
|
+
if ch == "(":
|
|
243
|
+
depth += 1
|
|
244
|
+
elif ch == ")":
|
|
245
|
+
depth -= 1
|
|
246
|
+
elif depth == 0 and re.match(r"\bfrom\b", sql[i:], re.IGNORECASE):
|
|
247
|
+
list_end = i
|
|
248
|
+
break
|
|
249
|
+
i += 1
|
|
250
|
+
select_list = sql[start:list_end if list_end is not None else len(sql)]
|
|
251
|
+
# Paren-aware split on top-level commas.
|
|
252
|
+
items, depth, buf = [], 0, []
|
|
253
|
+
for ch in select_list:
|
|
254
|
+
if ch == "(":
|
|
255
|
+
depth += 1
|
|
256
|
+
elif ch == ")":
|
|
257
|
+
depth -= 1
|
|
258
|
+
if ch == "," and depth == 0:
|
|
259
|
+
items.append("".join(buf)); buf = []
|
|
260
|
+
else:
|
|
261
|
+
buf.append(ch)
|
|
262
|
+
if buf:
|
|
263
|
+
items.append("".join(buf))
|
|
264
|
+
for raw in items:
|
|
265
|
+
item = raw.strip()
|
|
266
|
+
if not item:
|
|
267
|
+
continue
|
|
268
|
+
am = _SELECT_ALIAS.match(item)
|
|
269
|
+
if am:
|
|
270
|
+
expr, alias = am.group(1).strip(), am.group(2)
|
|
271
|
+
else:
|
|
272
|
+
expr, alias = item, item.split(".")[-1].strip('"')
|
|
273
|
+
if not _BARE_COLREF.match(expr):
|
|
274
|
+
out.add(alias.upper())
|
|
275
|
+
except Exception:
|
|
276
|
+
return out
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def bau_columns_differ(a: "BauColumn", b: "BauColumn") -> bool:
|
|
281
|
+
"""True when two same-named columns (from different tables) would drive
|
|
282
|
+
different page edits — the signal for the cross-table collision warning
|
|
283
|
+
in the CLI. Identity fields (name, is_pk, is_fk, type) are ignored."""
|
|
284
|
+
keys = (
|
|
285
|
+
"label", "description", "format_mask", "align_report", "align_form",
|
|
286
|
+
"text_case", "hidden", "read_only",
|
|
287
|
+
"report_display_order", "form_display_order",
|
|
288
|
+
)
|
|
289
|
+
return any(getattr(a, k) != getattr(b, k) for k in keys)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
293
|
+
# SQLcl `apex import` driver
|
|
294
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
DEFAULT_SQLCL = (
|
|
296
|
+
"$HOME/.vscode/extensions/oracle.sql-developer-26.1.2-darwin-arm64/"
|
|
297
|
+
"dbtools/sqlcl/bin/sql"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class ImportResult:
|
|
303
|
+
returncode: int
|
|
304
|
+
stdout: str
|
|
305
|
+
stderr: str
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def ok(self) -> bool:
|
|
309
|
+
# SQLcl exits 0 on success. Errors that don't bubble to exit-code
|
|
310
|
+
# (like ORA-* inside the script, or APEXLang-level "workspace is
|
|
311
|
+
# invalid" diagnostics) need string-matching the output.
|
|
312
|
+
if self.returncode != 0:
|
|
313
|
+
return False
|
|
314
|
+
haystack = (self.stdout or "") + "\n" + (self.stderr or "")
|
|
315
|
+
# The import's own verdict line is authoritative. SQLcl 26.1 prints
|
|
316
|
+
# APEXLang compile WARNINGS (e.g. "Invalid template option value")
|
|
317
|
+
# for unrelated pages and still imports fine — token-scanning alone
|
|
318
|
+
# would flag those as failures.
|
|
319
|
+
success_markers = ("importação bem-sucedida", "import successful")
|
|
320
|
+
if any(s in haystack.lower() for s in success_markers):
|
|
321
|
+
return True
|
|
322
|
+
bad_tokens = (
|
|
323
|
+
"ORA-", "PLS-", "SP2-",
|
|
324
|
+
"SQL Error",
|
|
325
|
+
"inválido", "invalid", # PT/EN: invalid argument / workspace
|
|
326
|
+
"Falha", "failure",
|
|
327
|
+
"Erro de", "Error:",
|
|
328
|
+
"Token inesperado", # SQLcl parse error
|
|
329
|
+
)
|
|
330
|
+
return not any(t.lower() in haystack.lower() for t in bad_tokens)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def run_apex_import(
|
|
334
|
+
sqlcl_path: str,
|
|
335
|
+
connection: str,
|
|
336
|
+
workspace: str,
|
|
337
|
+
input_path: Path,
|
|
338
|
+
deployment: Path | None = None,
|
|
339
|
+
) -> ImportResult:
|
|
340
|
+
"""Invoke `sql apex import -workspace <ws> -input <app_root>` against a
|
|
341
|
+
named SQLcl connection. Returns the captured stdout/stderr.
|
|
342
|
+
|
|
343
|
+
`input_path` must be the APP ROOT of a split export — the directory
|
|
344
|
+
holding `application.apx`, `pages/` and `shared-components/`. SQLcl's
|
|
345
|
+
`-input` only accepts a single FILE when that file contains the WHOLE
|
|
346
|
+
application; importing one page's .apx standalone fails with
|
|
347
|
+
REFERENCE_NOT_FOUND on theme references such as `@/text`, because the
|
|
348
|
+
shared components the page points at aren't in the input.
|
|
349
|
+
|
|
350
|
+
`deployment` optionally points at a deployments/*.json manifest (sets
|
|
351
|
+
the target application id and runtime options).
|
|
352
|
+
|
|
353
|
+
Connection MUST already exist in the user's SQLcl connection manager
|
|
354
|
+
(the same `connect -name <NAME>` pattern used elsewhere in the project).
|
|
355
|
+
SQLcl 26.1+ is required for the `apex import` subcommand — older
|
|
356
|
+
standalone SQLcl installations don't ship it.
|
|
357
|
+
"""
|
|
358
|
+
sql = os.path.expandvars(sqlcl_path)
|
|
359
|
+
if not Path(sql).is_file():
|
|
360
|
+
return ImportResult(
|
|
361
|
+
returncode=127, stdout="",
|
|
362
|
+
stderr=f"SQLcl not found at {sql}. "
|
|
363
|
+
f"Override with --sqlcl <path>.",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
dep = f" -deployment {deployment}" if deployment else ""
|
|
367
|
+
script = (
|
|
368
|
+
f"set echo off\n"
|
|
369
|
+
f"set feedback on\n"
|
|
370
|
+
f"connect -name {connection}\n"
|
|
371
|
+
f"apex import -workspace {workspace} -input {input_path}{dep}\n"
|
|
372
|
+
f"exit\n"
|
|
373
|
+
)
|
|
374
|
+
proc = subprocess.run(
|
|
375
|
+
[sql, "/nolog"],
|
|
376
|
+
input=script, capture_output=True, text=True, timeout=300,
|
|
377
|
+
)
|
|
378
|
+
# SQLcl exits 0 even when `connect -name` fails (Unknown connection /
|
|
379
|
+
# SP2-0640: not connected) — the `apex import` then runs against no
|
|
380
|
+
# session and is a silent no-op. Detect that and surface a real failure
|
|
381
|
+
# so callers don't report "returned code 0" as success.
|
|
382
|
+
combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).lower()
|
|
383
|
+
conn_markers = (
|
|
384
|
+
"unknown connection", "sp2-0640", "not connected", "não conectado",
|
|
385
|
+
"nao conectado",
|
|
386
|
+
)
|
|
387
|
+
rc = proc.returncode
|
|
388
|
+
if rc == 0 and any(m in combined for m in conn_markers):
|
|
389
|
+
rc = 2
|
|
390
|
+
extra = (f"\n[baucli] SQLcl could not open named connection "
|
|
391
|
+
f"'{connection}'. Check bau.toml [connection].name (or "
|
|
392
|
+
f"--connection) against your SQLcl connections "
|
|
393
|
+
f"(`sql -name {connection}` / connmgr list).")
|
|
394
|
+
return ImportResult(returncode=rc,
|
|
395
|
+
stdout=proc.stdout,
|
|
396
|
+
stderr=(proc.stderr or "") + extra)
|
|
397
|
+
return ImportResult(
|
|
398
|
+
returncode=rc,
|
|
399
|
+
stdout=proc.stdout,
|
|
400
|
+
stderr=proc.stderr,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
405
|
+
# File discovery
|
|
406
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
407
|
+
def _resolve_root_and_appdir(start: Path, app_dir: str) -> tuple[Path | None, str]:
|
|
408
|
+
"""Walk up from `start` looking for the first ancestor that contains
|
|
409
|
+
`apex/<app_dir>/pages/`. Returns `(ancestor, effective_app_dir)` or
|
|
410
|
+
`(None, app_dir)` if not found.
|
|
411
|
+
|
|
412
|
+
This lets the user run from any subdirectory of the project — instead
|
|
413
|
+
of failing because cwd happened to be `demo_crm/teste/` while the app
|
|
414
|
+
lives in `demo_crm/apex/...`.
|
|
415
|
+
|
|
416
|
+
Two UX tolerances on `app_dir`:
|
|
417
|
+
* a leading `apex/` is stripped (`--app-dir apex/app_104` works);
|
|
418
|
+
* when `apex/<app_dir>` exists but holds the pages one level deeper
|
|
419
|
+
in a SINGLE subdirectory (SQLcl split exports nest the app under
|
|
420
|
+
its alias, e.g. `app_104/demo-crm/pages`), that child is
|
|
421
|
+
auto-selected.
|
|
422
|
+
"""
|
|
423
|
+
app_dir = app_dir.strip("/")
|
|
424
|
+
if app_dir.startswith("apex/"):
|
|
425
|
+
app_dir = app_dir[5:]
|
|
426
|
+
for d in [start, *start.parents]:
|
|
427
|
+
base = d / "apex" / app_dir
|
|
428
|
+
if (base / "pages").is_dir():
|
|
429
|
+
return d, app_dir
|
|
430
|
+
if base.is_dir():
|
|
431
|
+
kids = [k for k in sorted(base.iterdir())
|
|
432
|
+
if k.is_dir() and (k / "pages").is_dir()]
|
|
433
|
+
if len(kids) == 1:
|
|
434
|
+
return d, f"{app_dir}/{kids[0].name}"
|
|
435
|
+
return None, app_dir
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _resolve_project_root(start: Path, app_dir: str) -> Path | None:
|
|
439
|
+
"""Backward-compatible wrapper over `_resolve_root_and_appdir`."""
|
|
440
|
+
root, _ = _resolve_root_and_appdir(start, app_dir)
|
|
441
|
+
return root
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def find_pages_referencing(
|
|
445
|
+
project_root: Path, app_dir: str, tables: Iterable[str] = ()
|
|
446
|
+
) -> tuple[Path, list[Path]]:
|
|
447
|
+
"""Find every .apx in `<project>/apex/<app_dir>/pages/` whose source
|
|
448
|
+
references any of the given tables (as detected by `discover_tables`).
|
|
449
|
+
|
|
450
|
+
Used by the directory-wide rebuild modes:
|
|
451
|
+
* `--table FOO` (no --page) sweeps every page where FOO appears in a
|
|
452
|
+
`tableName:` line or a `FROM/JOIN` clause;
|
|
453
|
+
* NO --table and NO --page sweeps EVERY page in the app — pass an
|
|
454
|
+
empty `tables` and all .apx files are returned (pages whose tables
|
|
455
|
+
aren't in BAU are skipped later by the rebuild loop).
|
|
456
|
+
|
|
457
|
+
Returns `(resolved_root, sorted_list_of_apx_paths)`.
|
|
458
|
+
"""
|
|
459
|
+
resolved, app_dir = _resolve_root_and_appdir(project_root, app_dir)
|
|
460
|
+
if resolved is None:
|
|
461
|
+
chain = [str(d / "apex" / app_dir / "pages") for d in
|
|
462
|
+
[project_root, *project_root.parents][:5]]
|
|
463
|
+
raise FileNotFoundError(
|
|
464
|
+
f"App directory '{app_dir}' has no pages folder under cwd or any "
|
|
465
|
+
f"of its parents. Tried (closest first):\n - "
|
|
466
|
+
+ "\n - ".join(chain)
|
|
467
|
+
)
|
|
468
|
+
targets = {t.upper() for t in tables}
|
|
469
|
+
pages_dir = resolved / "apex" / app_dir / "pages"
|
|
470
|
+
matches: list[Path] = []
|
|
471
|
+
for f in sorted(pages_dir.glob("*.apx")):
|
|
472
|
+
if not targets:
|
|
473
|
+
matches.append(f)
|
|
474
|
+
continue
|
|
475
|
+
try:
|
|
476
|
+
txt = f.read_text(encoding="utf-8")
|
|
477
|
+
except OSError:
|
|
478
|
+
continue
|
|
479
|
+
if targets & set(discover_tables(txt)):
|
|
480
|
+
matches.append(f)
|
|
481
|
+
return resolved, matches
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def find_apex_file(
|
|
485
|
+
project_root: Path, app_dir: str, page_id: int
|
|
486
|
+
) -> tuple[Path, Path]:
|
|
487
|
+
"""Locate the .apx file for one page inside an app directory.
|
|
488
|
+
|
|
489
|
+
The convention is `<project-root>/apex/<app_dir>/pages/p<padded>-*.apx`.
|
|
490
|
+
`project_root` is treated as a *hint*: if `apex/<app_dir>/pages/` isn't
|
|
491
|
+
directly under it, we walk up looking for the closest ancestor that has
|
|
492
|
+
it. Returns `(resolved_root, apex_file_path)` so the caller can render
|
|
493
|
+
paths relative to the actually-used root.
|
|
494
|
+
"""
|
|
495
|
+
padded = f"p{page_id:05d}-*.apx"
|
|
496
|
+
resolved, app_dir = _resolve_root_and_appdir(project_root, app_dir)
|
|
497
|
+
if resolved is None:
|
|
498
|
+
# Show the chain we walked so the user can see where we looked.
|
|
499
|
+
chain = [str(d / "apex" / app_dir / "pages") for d in
|
|
500
|
+
[project_root, *project_root.parents][:5]]
|
|
501
|
+
raise FileNotFoundError(
|
|
502
|
+
f"App directory '{app_dir}' has no pages folder under cwd or any "
|
|
503
|
+
f"of its parents. Tried (closest first):\n - "
|
|
504
|
+
+ "\n - ".join(chain)
|
|
505
|
+
+ f"\nHint: cd to your project root or pass --project-root "
|
|
506
|
+
f"<path-containing-apex/>."
|
|
507
|
+
)
|
|
508
|
+
pages = resolved / "apex" / app_dir / "pages"
|
|
509
|
+
candidates = list(pages.glob(padded))
|
|
510
|
+
if not candidates:
|
|
511
|
+
raise FileNotFoundError(
|
|
512
|
+
f"No .apx for page {page_id} in {pages.relative_to(resolved)} "
|
|
513
|
+
f"(looked for {padded})."
|
|
514
|
+
)
|
|
515
|
+
if len(candidates) > 1:
|
|
516
|
+
listing = "\n - ".join(str(p.relative_to(resolved)) for p in candidates)
|
|
517
|
+
raise FileNotFoundError(
|
|
518
|
+
f"Multiple .apx files match page {page_id}:\n - {listing}"
|
|
519
|
+
)
|
|
520
|
+
return resolved, candidates[0]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
524
|
+
# Block extraction — finds `pageItem NAME (`, `column NAME (`, etc.
|
|
525
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
526
|
+
_BLOCK_RE = re.compile(
|
|
527
|
+
r"""
|
|
528
|
+
^([ \t]*) # leading indent
|
|
529
|
+
(pageItem|column|displayColumn) # block type
|
|
530
|
+
[ \t]+
|
|
531
|
+
([A-Za-z_][A-Za-z0-9_]*) # block name
|
|
532
|
+
[ \t]*\( # opening paren
|
|
533
|
+
""",
|
|
534
|
+
re.VERBOSE | re.MULTILINE,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@dataclass
|
|
539
|
+
class Block:
|
|
540
|
+
kind: str # 'pageItem' | 'column' | 'displayColumn'
|
|
541
|
+
name: str
|
|
542
|
+
indent: str
|
|
543
|
+
start: int # offset of the opening `(` line in the source
|
|
544
|
+
end: int # offset just past the matching `)`
|
|
545
|
+
text: str # the full block text (from indent to closing `)`)
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def bau_key(self) -> str:
|
|
549
|
+
"""Column name to look up in BAU metadata.
|
|
550
|
+
|
|
551
|
+
pageItem names usually follow P<page>_<COLUMN_NAME>; strip the
|
|
552
|
+
prefix when present so e.g. `P13_DOMAIN_CODE` matches BAU column
|
|
553
|
+
`DOMAIN_CODE`.
|
|
554
|
+
"""
|
|
555
|
+
n = self.name.upper()
|
|
556
|
+
m = re.match(r"^P\d+_(.+)$", n)
|
|
557
|
+
return (m.group(1) if m else n)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def find_matching_paren(text: str, open_idx: int) -> int:
|
|
561
|
+
"""Return offset just past the `)` that matches the `(` at `open_idx`.
|
|
562
|
+
|
|
563
|
+
Walks the buffer char by char, tracks paren depth, and skips over double-
|
|
564
|
+
quoted string literals and ```-fenced multi-line strings (used by
|
|
565
|
+
APEXLang for embedded SQL). Comments aren't expected inside block bodies.
|
|
566
|
+
"""
|
|
567
|
+
assert text[open_idx] == "(", f"expected '(' at {open_idx}"
|
|
568
|
+
depth = 1
|
|
569
|
+
i = open_idx + 1
|
|
570
|
+
n = len(text)
|
|
571
|
+
while i < n:
|
|
572
|
+
c = text[i]
|
|
573
|
+
# SQL/MD-style triple-backtick block — skip until closing fence.
|
|
574
|
+
if c == "`" and text[i : i + 3] == "```":
|
|
575
|
+
end = text.find("```", i + 3)
|
|
576
|
+
i = (end + 3) if end != -1 else n
|
|
577
|
+
continue
|
|
578
|
+
# Double-quoted string literal — APEXLang doesn't typically use these,
|
|
579
|
+
# but a defensive skip protects against false-positive parens inside.
|
|
580
|
+
if c == '"':
|
|
581
|
+
i = _skip_quoted(text, i, '"') + 1
|
|
582
|
+
continue
|
|
583
|
+
if c == "(":
|
|
584
|
+
depth += 1
|
|
585
|
+
elif c == ")":
|
|
586
|
+
depth -= 1
|
|
587
|
+
if depth == 0:
|
|
588
|
+
return i + 1
|
|
589
|
+
i += 1
|
|
590
|
+
raise ValueError(f"unbalanced paren starting at offset {open_idx}")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _skip_quoted(text: str, start: int, quote: str) -> int:
|
|
594
|
+
"""Return the offset of the closing quote (matching `quote`), supporting
|
|
595
|
+
backslash escapes. If unterminated, returns len(text)-1."""
|
|
596
|
+
i = start + 1
|
|
597
|
+
n = len(text)
|
|
598
|
+
while i < n:
|
|
599
|
+
if text[i] == "\\" and i + 1 < n:
|
|
600
|
+
i += 2
|
|
601
|
+
continue
|
|
602
|
+
if text[i] == quote:
|
|
603
|
+
return i
|
|
604
|
+
i += 1
|
|
605
|
+
return n - 1
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def iter_blocks(source: str, kinds: Iterable[str] = ("pageItem", "column",
|
|
609
|
+
"displayColumn")
|
|
610
|
+
) -> list[Block]:
|
|
611
|
+
"""Return every `kind NAME ( ... )` block in source, in file order."""
|
|
612
|
+
kinds = set(kinds)
|
|
613
|
+
out: list[Block] = []
|
|
614
|
+
for m in _BLOCK_RE.finditer(source):
|
|
615
|
+
if m.group(2) not in kinds:
|
|
616
|
+
continue
|
|
617
|
+
open_paren = source.find("(", m.end() - 1)
|
|
618
|
+
if open_paren == -1:
|
|
619
|
+
continue
|
|
620
|
+
try:
|
|
621
|
+
end = find_matching_paren(source, open_paren)
|
|
622
|
+
except ValueError:
|
|
623
|
+
continue
|
|
624
|
+
out.append(Block(
|
|
625
|
+
kind=m.group(2),
|
|
626
|
+
name=m.group(3),
|
|
627
|
+
indent=m.group(1),
|
|
628
|
+
start=m.start(),
|
|
629
|
+
end=end,
|
|
630
|
+
text=source[m.start():end],
|
|
631
|
+
))
|
|
632
|
+
return out
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
636
|
+
# Block-level edits — operate on the block text only, return the new text.
|
|
637
|
+
# Each editor is idempotent: running twice gives the same result.
|
|
638
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
639
|
+
_KV_RE = lambda key: re.compile(
|
|
640
|
+
rf"^([ \t]*){re.escape(key)}\s*:\s*(.*?)$", re.MULTILINE
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _insert_before_close_line(block: str, insertion: str) -> str:
|
|
645
|
+
"""Insert `insertion` (already newline-terminated) on its own line(s)
|
|
646
|
+
immediately before the line that contains the block's closing `)`.
|
|
647
|
+
|
|
648
|
+
Splicing at the bare `block.rfind(")")` would land AFTER the closing
|
|
649
|
+
paren's own indent, doubling the leading whitespace of every line in
|
|
650
|
+
the insertion. Walking back to the start of that line first puts the
|
|
651
|
+
new content on its own lines, with the `)` line untouched.
|
|
652
|
+
"""
|
|
653
|
+
close = block.rfind(")")
|
|
654
|
+
line_start = block.rfind("\n", 0, close) + 1 # 0 if no newline (shouldn't happen)
|
|
655
|
+
return block[:line_start] + insertion + block[line_start:]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# For each block the engine may insert, which sibling blocks MUST come
|
|
659
|
+
# after it according to APEX MMD 26's parser. Insertion is placed
|
|
660
|
+
# immediately before the first matching successor found in the column
|
|
661
|
+
# body. When no successor is found, falls back to inserting just before
|
|
662
|
+
# the closing `)` of the parent.
|
|
663
|
+
#
|
|
664
|
+
# Modeling chose this map-of-successors over a single global ordering
|
|
665
|
+
# because empirically the 2,186 column blocks in the project's sample
|
|
666
|
+
# apps disagree on edge cases (e.g. some have `settings → layout`,
|
|
667
|
+
# others have `layout → settings`). What the parser cares about is the
|
|
668
|
+
# specific pairings that violate the schema — and the only one we keep
|
|
669
|
+
# bumping into is `appearance` after `source`. The successor lists below
|
|
670
|
+
# encode every such constraint relevant to the blocks we insert,
|
|
671
|
+
# avoiding false confidence in a globally-unique canonical order.
|
|
672
|
+
INSERT_SUCCESSORS: dict[str, tuple[str, ...]] = {
|
|
673
|
+
"heading": ("layout", "lov", "settings", "validation",
|
|
674
|
+
"appearance", "link", "source", "advanced", "security"),
|
|
675
|
+
"layout": ("lov", "settings", "validation",
|
|
676
|
+
"appearance", "link", "source", "advanced", "security"),
|
|
677
|
+
"appearance": ("source", "advanced", "security",
|
|
678
|
+
"exportPrinting", "sorting", "columnFilter"),
|
|
679
|
+
# `settings` precedes layout/source/etc. in EVERY sampled export
|
|
680
|
+
# (409 settings→layout pairs, zero reversed).
|
|
681
|
+
"settings": ("layout", "lov", "validation", "appearance", "link",
|
|
682
|
+
"source", "advanced", "security", "sessionState",
|
|
683
|
+
"default", "columnFilter", "sorting", "exportPrinting"),
|
|
684
|
+
# pageItem order in the samples: label → lov → layout → ...
|
|
685
|
+
"lov": ("layout", "validation", "appearance", "link", "source",
|
|
686
|
+
"advanced", "security", "sessionState", "default"),
|
|
687
|
+
# `default` is a late block (after source/appearance) but before
|
|
688
|
+
# security/advanced/help — place it ahead of the first of those present.
|
|
689
|
+
"default": ("security", "advanced", "help"),
|
|
690
|
+
# `help` is the LAST nested block in the samples (259 ✕→help pairs
|
|
691
|
+
# dominate) — the empty successor list makes the fallback ("insert
|
|
692
|
+
# before the closing paren") land it exactly there.
|
|
693
|
+
"help": (),
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# Server-side / expression defaults that must NOT become a form item default
|
|
698
|
+
# — they are applied by the DB on INSERT, not chosen in the UI. The safe
|
|
699
|
+
# filter accepts ONLY a quoted string literal or a plain number; everything
|
|
700
|
+
# else (sequences, SYSDATE/SYSTIMESTAMP, SYS_GUID(), USER, expressions, bare
|
|
701
|
+
# identifiers) is rejected.
|
|
702
|
+
def _safe_default(raw: str | None) -> str | None:
|
|
703
|
+
"""Return a UI-safe literal from a raw DB DATA_DEFAULT, or None when the
|
|
704
|
+
default is server-side / an expression. Strips the quotes of a string
|
|
705
|
+
literal ('S' → S) and passes numbers through (0, -1, 1.5)."""
|
|
706
|
+
if not raw:
|
|
707
|
+
return None
|
|
708
|
+
v = raw.strip()
|
|
709
|
+
if not v:
|
|
710
|
+
return None
|
|
711
|
+
m = re.match(r"^'(.*)'$", v, re.DOTALL) # quoted string literal
|
|
712
|
+
if m:
|
|
713
|
+
return m.group(1).replace("''", "'") or None
|
|
714
|
+
if re.match(r"^-?\d+(\.\d+)?$", v): # numeric literal
|
|
715
|
+
return v
|
|
716
|
+
return None # anything else is unsafe
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _insert_block_in_order(block: str, new_block_name: str,
|
|
720
|
+
content: str) -> str:
|
|
721
|
+
"""Insert `content` immediately before the first sibling listed in
|
|
722
|
+
INSERT_SUCCESSORS[new_block_name] that exists in `block`. Falls back
|
|
723
|
+
to inserting before the closing `)` when no successor is found —
|
|
724
|
+
that's safe because the parser only rejects out-of-order placement,
|
|
725
|
+
and putting a new block last when no other constraints exist matches
|
|
726
|
+
what the samples do."""
|
|
727
|
+
successors = INSERT_SUCCESSORS.get(new_block_name, ())
|
|
728
|
+
for later in successors:
|
|
729
|
+
m = re.search(
|
|
730
|
+
rf"^([ \t]*){re.escape(later)}\s*\{{",
|
|
731
|
+
block, re.MULTILINE,
|
|
732
|
+
)
|
|
733
|
+
if m:
|
|
734
|
+
return block[:m.start()] + content + block[m.start():]
|
|
735
|
+
return _insert_before_close_line(block, content)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _set_or_add_kv(block: str, key: str, value: str, indent: str) -> str:
|
|
739
|
+
"""Set `key: value` inside block. If key already exists, replace its
|
|
740
|
+
value. Otherwise insert just before the closing `)`."""
|
|
741
|
+
if value is None:
|
|
742
|
+
return block
|
|
743
|
+
pat = _KV_RE(key)
|
|
744
|
+
if pat.search(block):
|
|
745
|
+
return pat.sub(lambda m: f"{m.group(1)}{key}: {value}", block, count=1)
|
|
746
|
+
return _insert_before_close_line(block, f"{indent}{key}: {value}\n")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _top_kv_match(block: str, key: str, inner_indent: str):
|
|
750
|
+
"""Match `key: value` only at the block's OWN indentation level. A bare
|
|
751
|
+
multiline search for e.g. `type:` also hits the nested one inside
|
|
752
|
+
`source { type: sqlQuery }` — depth filtering by indent avoids editing
|
|
753
|
+
the wrong property."""
|
|
754
|
+
for m in _KV_RE(key).finditer(block):
|
|
755
|
+
if m.group(1) == inner_indent:
|
|
756
|
+
return m
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _set_top_kv(block: str, key: str, value: str, inner_indent: str) -> str:
|
|
761
|
+
"""Set a block-level scalar (`type: hidden` etc.) at depth 1 only.
|
|
762
|
+
When absent, insert as the FIRST line of the block — scalar properties
|
|
763
|
+
like `type:` lead every sampled pageItem/column block, and the parser
|
|
764
|
+
can reject them after nested blocks."""
|
|
765
|
+
m = _top_kv_match(block, key, inner_indent)
|
|
766
|
+
if m:
|
|
767
|
+
return block[:m.start()] + f"{m.group(1)}{key}: {value}" + block[m.end():]
|
|
768
|
+
open_idx = block.find("(")
|
|
769
|
+
line_end = block.find("\n", open_idx)
|
|
770
|
+
if line_end == -1:
|
|
771
|
+
return _insert_before_close_line(block, f"{inner_indent}{key}: {value}\n")
|
|
772
|
+
return (block[:line_end + 1]
|
|
773
|
+
+ f"{inner_indent}{key}: {value}\n"
|
|
774
|
+
+ block[line_end + 1:])
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _remove_nested_kv(block: str, parent: str, key: str) -> tuple[str, str | None]:
|
|
778
|
+
"""Remove `key: ...` from inside a multi-line `parent { ... }` block.
|
|
779
|
+
Returns `(new_block, removed_value)`; `removed_value` is None when the
|
|
780
|
+
key wasn't present. When the removal leaves the parent empty, the whole
|
|
781
|
+
parent block is dropped — no sampled export carries an empty `{}`."""
|
|
782
|
+
multi_pat = re.compile(
|
|
783
|
+
rf"^([ \t]*){re.escape(parent)}\s*\{{(.*?)^\1\}}",
|
|
784
|
+
re.MULTILINE | re.DOTALL,
|
|
785
|
+
)
|
|
786
|
+
m = multi_pat.search(block)
|
|
787
|
+
if not m:
|
|
788
|
+
return block, None
|
|
789
|
+
inner = m.group(2)
|
|
790
|
+
kv = _KV_RE(key).search(inner)
|
|
791
|
+
if not kv:
|
|
792
|
+
return block, None
|
|
793
|
+
removed = kv.group(2).strip()
|
|
794
|
+
line_end = inner.find("\n", kv.end())
|
|
795
|
+
line_end = len(inner) if line_end == -1 else line_end + 1
|
|
796
|
+
new_inner = inner[:kv.start()] + inner[line_end:]
|
|
797
|
+
if not new_inner.strip():
|
|
798
|
+
end = m.end()
|
|
799
|
+
if block[end:end + 1] == "\n":
|
|
800
|
+
end += 1
|
|
801
|
+
return block[:m.start()] + block[end:], removed
|
|
802
|
+
rebuilt = f"{m.group(1)}{parent} {{{new_inner}{m.group(1)}}}"
|
|
803
|
+
return block[:m.start()] + rebuilt + block[m.end():], removed
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _set_or_add_nested_kv(block: str, parent: str, key: str, value: str,
|
|
807
|
+
indent: str, inner_indent: str) -> str:
|
|
808
|
+
"""Set `parent { ... key: value ... }`. If parent block exists (either as
|
|
809
|
+
a multi-line block or a single-line `parent { … }`), update the key
|
|
810
|
+
inside it — expanding to multi-line form for predictable indentation.
|
|
811
|
+
If the parent block doesn't exist, insert a new multi-line block at
|
|
812
|
+
the canonical APEXLang position."""
|
|
813
|
+
if value is None:
|
|
814
|
+
return block
|
|
815
|
+
|
|
816
|
+
# Multi-line: parent { ... \n<indent>} — closing `}` on its own line.
|
|
817
|
+
multi_pat = re.compile(
|
|
818
|
+
rf"^([ \t]*){re.escape(parent)}\s*\{{(.*?)^\1\}}",
|
|
819
|
+
re.MULTILINE | re.DOTALL,
|
|
820
|
+
)
|
|
821
|
+
# Single-line: parent { ... } on one line, no nested braces inside.
|
|
822
|
+
inline_pat = re.compile(
|
|
823
|
+
rf"^([ \t]*){re.escape(parent)}\s*\{{\s*([^{{}}\n]*?)\s*\}}\s*$",
|
|
824
|
+
re.MULTILINE,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
m = multi_pat.search(block)
|
|
828
|
+
is_inline = False
|
|
829
|
+
if not m:
|
|
830
|
+
m = inline_pat.search(block)
|
|
831
|
+
is_inline = m is not None
|
|
832
|
+
|
|
833
|
+
if m:
|
|
834
|
+
block_indent = m.group(1)
|
|
835
|
+
inner = m.group(2)
|
|
836
|
+
kv = _KV_RE(key)
|
|
837
|
+
if kv.search(inner):
|
|
838
|
+
new_inner = kv.sub(
|
|
839
|
+
lambda mm: f"{mm.group(1)}{key}: {value}", inner, count=1
|
|
840
|
+
)
|
|
841
|
+
else:
|
|
842
|
+
# Append the new key, preserving the existing entry indent when
|
|
843
|
+
# the block is multi-line. For inline blocks, fall back to
|
|
844
|
+
# `inner_indent` since the inner had no indentation at all.
|
|
845
|
+
existing = re.search(r"^([ \t]+)\S", inner, re.MULTILINE)
|
|
846
|
+
child_indent = existing.group(1) if existing else inner_indent
|
|
847
|
+
new_inner = inner.rstrip("\n") + f"\n{child_indent}{key}: {value}\n"
|
|
848
|
+
|
|
849
|
+
if is_inline:
|
|
850
|
+
# Expand inline → multi-line for consistent shape. The inner
|
|
851
|
+
# captured from the single-line regex has no leading newline /
|
|
852
|
+
# indentation, so rebuild it with proper indents.
|
|
853
|
+
inner_lines = [
|
|
854
|
+
ln for ln in new_inner.strip().splitlines() if ln.strip()
|
|
855
|
+
]
|
|
856
|
+
rebuilt = "\n".join(
|
|
857
|
+
f"{inner_indent}{ln.strip()}" for ln in inner_lines
|
|
858
|
+
)
|
|
859
|
+
replacement = (
|
|
860
|
+
f"{block_indent}{parent} {{\n{rebuilt}\n{block_indent}}}"
|
|
861
|
+
)
|
|
862
|
+
else:
|
|
863
|
+
replacement = (
|
|
864
|
+
f"{block_indent}{parent} {{{new_inner}{block_indent}}}"
|
|
865
|
+
)
|
|
866
|
+
return block[:m.start()] + replacement + block[m.end():]
|
|
867
|
+
|
|
868
|
+
# parent block doesn't exist — insert at the canonical APEXLang
|
|
869
|
+
# ordering position so the parser doesn't reject it (e.g. `appearance`
|
|
870
|
+
# must come before `source` in APEX 26).
|
|
871
|
+
insertion = (
|
|
872
|
+
f"{indent}{parent} {{\n"
|
|
873
|
+
f"{inner_indent}{key}: {value}\n"
|
|
874
|
+
f"{indent}}}\n"
|
|
875
|
+
)
|
|
876
|
+
return _insert_block_in_order(block, parent, insertion)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
880
|
+
# Top-level rebuild — given source text + dict of BauColumn, return new source
|
|
881
|
+
# plus a list of (column_name, what_changed) tuples.
|
|
882
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
883
|
+
@dataclass
|
|
884
|
+
class Change:
|
|
885
|
+
block: Block
|
|
886
|
+
field: str
|
|
887
|
+
old: str | None
|
|
888
|
+
new: str | None
|
|
889
|
+
|
|
890
|
+
def render(self) -> str:
|
|
891
|
+
# Escape `[…]` for Rich — otherwise `[pageItem …]` is consumed as
|
|
892
|
+
# markup and disappears from the terminal output. Long values
|
|
893
|
+
# (helpText carries full column descriptions) are truncated.
|
|
894
|
+
def _t(v):
|
|
895
|
+
return (v[:57] + "…") if isinstance(v, str) and len(v) > 58 else v
|
|
896
|
+
return (f" \\[{self.block.kind} {self.block.name}] {self.field}: "
|
|
897
|
+
f"{_t(self.old)!r} → {_t(self.new)!r}")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@dataclass
|
|
901
|
+
class RebuildResult:
|
|
902
|
+
new_source: str
|
|
903
|
+
changes: list[Change] = field(default_factory=list)
|
|
904
|
+
skipped: list[tuple[str, str]] = field(default_factory=list) # (block name, reason)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _quote_apexlang(s: str) -> str:
|
|
908
|
+
"""APEXLang values are bare tokens or quoted with single/double quotes
|
|
909
|
+
depending on content. For labels we use the raw form (no surrounding
|
|
910
|
+
quotes) because that's what existing files in this repo do."""
|
|
911
|
+
# Strip leading/trailing whitespace; preserve PT-BR accents as-is.
|
|
912
|
+
return s.strip() if s else ""
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def rebuild_apex_source(source: str, columns: dict[str, BauColumn],
|
|
916
|
+
rows_per_page: int | None = None) -> RebuildResult:
|
|
917
|
+
"""Apply BAU column metadata to every recognized block in `source`.
|
|
918
|
+
|
|
919
|
+
When `rows_per_page` is given, every savedReport `view { rowsPerPage: N }`
|
|
920
|
+
on the page is set to it (the BAU_CONFIG DOCTAB.REPORT_ROWS_PER_PAGE value,
|
|
921
|
+
resolved by GET_COLUMNS per schema)."""
|
|
922
|
+
blocks = iter_blocks(source, kinds=("pageItem", "column", "displayColumn"))
|
|
923
|
+
# Apply from the END of the file backwards so offsets in earlier blocks
|
|
924
|
+
# remain valid as we splice text in.
|
|
925
|
+
changes: list[Change] = []
|
|
926
|
+
skipped: list[tuple[str, str]] = []
|
|
927
|
+
buf = source
|
|
928
|
+
# Columns whose IR sqlQuery projects a DERIVED value (FK shown as a name
|
|
929
|
+
# via scalar subquery, etc.) — they display a string, so numeric-ID
|
|
930
|
+
# metadata (center align / 000000 mask) must not be applied to them.
|
|
931
|
+
derived_cols = derived_display_cols(source)
|
|
932
|
+
blocks_sorted = sorted(blocks, key=lambda b: -b.start)
|
|
933
|
+
|
|
934
|
+
for blk in blocks_sorted:
|
|
935
|
+
col = columns.get(blk.bau_key)
|
|
936
|
+
if not col and blk.bau_key.endswith("_LABEL"):
|
|
937
|
+
# Domain columns are surfaced in IRs/IGs as a friendly-label
|
|
938
|
+
# alias (e.g. `ATIVO_LABEL` → FNC_BAU_DOMAIN_LABEL(...)). The
|
|
939
|
+
# alias is not a BAU column, so fall back to the base column so
|
|
940
|
+
# heading / format / align and especially REPORT_DISPLAY_ORDER
|
|
941
|
+
# (column ordering) still apply. Mirrors the conformity check's
|
|
942
|
+
# `_LABEL` normalization. Exact match wins first, so a real
|
|
943
|
+
# column literally named `*_LABEL` is never shadowed.
|
|
944
|
+
col = columns.get(blk.bau_key[:-len("_LABEL")])
|
|
945
|
+
if not col:
|
|
946
|
+
skipped.append((blk.name, "no matching BAU column"))
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
# The block's text in `buf` may have shifted if we already edited
|
|
950
|
+
# something *after* it. We're walking back-to-front so all later
|
|
951
|
+
# edits are already applied to buf; we re-extract this block's text
|
|
952
|
+
# from buf using its still-valid offsets (offsets earlier in file
|
|
953
|
+
# are unaffected by later splices).
|
|
954
|
+
block_text = buf[blk.start:blk.end]
|
|
955
|
+
new_block = block_text
|
|
956
|
+
# Inner indent: best guess from first non-blank inner line.
|
|
957
|
+
first_inner = re.search(r"^([ \t]+)\S", block_text[block_text.find("(") + 1:],
|
|
958
|
+
re.MULTILINE)
|
|
959
|
+
inner_indent = (blk.indent + " ") if not first_inner else first_inner.group(1)
|
|
960
|
+
deep_indent = inner_indent + " "
|
|
961
|
+
|
|
962
|
+
# ── label / heading / mask / align / hidden — most edits are
|
|
963
|
+
# skipped for `displayColumn` because that block is just a "use
|
|
964
|
+
# this column at this slot" pointer inside savedReport. Heading/
|
|
965
|
+
# mask/etc. ARE legal there (as overrides) but pages rarely set
|
|
966
|
+
# them by hand — forcing the BAU defaults in would clutter every
|
|
967
|
+
# saved report. We touch only `sequence` for displayColumn.
|
|
968
|
+
|
|
969
|
+
# Detect the block's own `type:` — depth-filtered, otherwise the
|
|
970
|
+
# nested `source { type: sqlQuery }` is matched instead.
|
|
971
|
+
type_match = _top_kv_match(new_block, "type", inner_indent)
|
|
972
|
+
block_type = type_match.group(2).strip() if type_match else None
|
|
973
|
+
block_is_hidden = (block_type == "hidden")
|
|
974
|
+
# APEX 26's parser rejects display-related properties on hidden
|
|
975
|
+
# columns/items (columnAlignment, formatMask, textCase, helpText —
|
|
976
|
+
# zero occurrences inside `type: hidden` blocks across all sampled
|
|
977
|
+
# exports). A block that BAU marks HIDDEN is flipped further below,
|
|
978
|
+
# so treat it as hidden ALREADY: skip adding those properties and
|
|
979
|
+
# strip any it carries.
|
|
980
|
+
effective_hidden = block_is_hidden or (
|
|
981
|
+
col.hidden and blk.kind != "displayColumn"
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
if blk.kind != "displayColumn" and effective_hidden:
|
|
985
|
+
for parent, key in (("appearance", "formatMask"),
|
|
986
|
+
("layout", "columnAlignment"),
|
|
987
|
+
("layout", "alignment")):
|
|
988
|
+
stripped, removed = _remove_nested_kv(new_block, parent, key)
|
|
989
|
+
if removed is not None:
|
|
990
|
+
new_block = stripped
|
|
991
|
+
changes.append(Change(blk, key, removed, None))
|
|
992
|
+
# `label` (scalar OR block form) is INVALID_PROPERTY on hidden
|
|
993
|
+
# pageItems (heading on hidden columns is fine — 190 occurrences
|
|
994
|
+
# in the samples; label inside hidden pageItems: zero).
|
|
995
|
+
if blk.kind == "pageItem":
|
|
996
|
+
lm = _top_kv_match(new_block, "label", inner_indent)
|
|
997
|
+
if lm:
|
|
998
|
+
line_end = new_block.find("\n", lm.end())
|
|
999
|
+
line_end = (len(new_block) if line_end == -1
|
|
1000
|
+
else line_end + 1)
|
|
1001
|
+
changes.append(
|
|
1002
|
+
Change(blk, "label", lm.group(2).strip(), None))
|
|
1003
|
+
new_block = new_block[:lm.start()] + new_block[line_end:]
|
|
1004
|
+
lb = re.search(r"^([ \t]*)label\s*\{.*?^\1\}\n?",
|
|
1005
|
+
new_block, re.MULTILINE | re.DOTALL)
|
|
1006
|
+
if lb:
|
|
1007
|
+
changes.append(Change(blk, "label", "{…}", None))
|
|
1008
|
+
new_block = new_block[:lb.start()] + new_block[lb.end():]
|
|
1009
|
+
|
|
1010
|
+
# ── form item type (pageItem only) ─────────────────────────────
|
|
1011
|
+
# BAU.FORM_ITEM_TYPE is the exact APEXLang token (checkbox, switch,
|
|
1012
|
+
# selectList, ...). Precedence: HIDDEN / READ_ONLY win; a page-side
|
|
1013
|
+
# `type: hidden` is also left alone (the page hides items for its
|
|
1014
|
+
# own reasons, e.g. dashboard filters).
|
|
1015
|
+
if (blk.kind == "pageItem" and col.form_item_type
|
|
1016
|
+
and not effective_hidden and not col.read_only
|
|
1017
|
+
and block_type not in ("hidden", col.form_item_type)):
|
|
1018
|
+
new_type = col.form_item_type
|
|
1019
|
+
has_lov = re.search(r"^[ \t]*lov\s*\{", new_block, re.MULTILINE)
|
|
1020
|
+
if new_type in LOV_ITEM_TYPES and not has_lov and not col.domain_values:
|
|
1021
|
+
# FK lookups etc. — can't synthesize an LOV query.
|
|
1022
|
+
skipped.append((blk.name,
|
|
1023
|
+
f"form_item_type {new_type} needs an lov "
|
|
1024
|
+
f"block (no BAU domain values to synthesize "
|
|
1025
|
+
f"one — FK lookup?)"))
|
|
1026
|
+
else:
|
|
1027
|
+
if new_type in LOV_ITEM_TYPES and not has_lov:
|
|
1028
|
+
# Synthesize a static LOV from the BAU-documented
|
|
1029
|
+
# CHECK-constraint domain (display;return pairs in the
|
|
1030
|
+
# STATIC2 grammar). `,` and `;` are structural in that
|
|
1031
|
+
# grammar — sanitized out of labels/values.
|
|
1032
|
+
def _san(s: str) -> str:
|
|
1033
|
+
return ((s or "").replace(",", " ")
|
|
1034
|
+
.replace(";", " ").strip())
|
|
1035
|
+
pairs = ",".join(f"{_san(lbl)};{_san(val)}"
|
|
1036
|
+
for val, lbl in col.domain_values)
|
|
1037
|
+
# Closed CHECK domain → NULL is never a valid member, so
|
|
1038
|
+
# the synthesized LOV must NOT offer a null option
|
|
1039
|
+
# (`displayNullValue: false`); otherwise APEX renders an
|
|
1040
|
+
# empty radio/entry that can't be saved on a NOT NULL flag.
|
|
1041
|
+
lov_block = (
|
|
1042
|
+
f"{inner_indent}lov {{\n"
|
|
1043
|
+
f"{deep_indent}type: staticValues\n"
|
|
1044
|
+
f"{deep_indent}staticValues: STATIC2:{pairs}\n"
|
|
1045
|
+
f"{deep_indent}displayExtraValues: false\n"
|
|
1046
|
+
f"{deep_indent}displayNullValue: false\n"
|
|
1047
|
+
f"{inner_indent}}}\n"
|
|
1048
|
+
)
|
|
1049
|
+
new_block = _insert_block_in_order(new_block, "lov",
|
|
1050
|
+
lov_block)
|
|
1051
|
+
changes.append(
|
|
1052
|
+
Change(blk, "lov", None, f"STATIC2:{pairs}"))
|
|
1053
|
+
has_lov = True
|
|
1054
|
+
# type-specific child blocks don't survive a retype: a
|
|
1055
|
+
# leftover lov on a checkbox (or textField settings on a
|
|
1056
|
+
# selectList) is INVALID_PROPERTY at import.
|
|
1057
|
+
if new_type not in LOV_ITEM_TYPES and has_lov:
|
|
1058
|
+
lov_m = re.search(r"^([ \t]*)lov\s*\{.*?^\1\}\n?",
|
|
1059
|
+
new_block, re.MULTILINE | re.DOTALL)
|
|
1060
|
+
if lov_m:
|
|
1061
|
+
changes.append(Change(blk, "lov", "{…}", None))
|
|
1062
|
+
new_block = (new_block[:lov_m.start()]
|
|
1063
|
+
+ new_block[lov_m.end():])
|
|
1064
|
+
set_m = re.search(r"^([ \t]*)settings\s*\{.*?^\1\}\n?",
|
|
1065
|
+
new_block, re.MULTILINE | re.DOTALL)
|
|
1066
|
+
if set_m:
|
|
1067
|
+
changes.append(Change(blk, "settings", "{…}", None))
|
|
1068
|
+
new_block = (new_block[:set_m.start()]
|
|
1069
|
+
+ new_block[set_m.end():])
|
|
1070
|
+
new_block = _set_top_kv(new_block, "type", new_type,
|
|
1071
|
+
inner_indent)
|
|
1072
|
+
changes.append(Change(blk, "type", block_type, new_type))
|
|
1073
|
+
block_type = new_type
|
|
1074
|
+
|
|
1075
|
+
# Single checkbox numa coluna com domínio: o strip de settings
|
|
1076
|
+
# acima removeu os valores antigos e checkbox não usa lov —
|
|
1077
|
+
# sem checkedValue/uncheckedValue o item envia valor inválido e
|
|
1078
|
+
# viola o CHECK da coluna (ex.: CK_..._ATIVO: ATIVO IN ('S','N')).
|
|
1079
|
+
# Sintetiza o settings a partir do domínio documentado.
|
|
1080
|
+
if new_type == "checkbox":
|
|
1081
|
+
if len(col.domain_values) == 2:
|
|
1082
|
+
_truthy = {"S", "Y", "1", "T", "SIM", "YES", "TRUE", "ATIVO"}
|
|
1083
|
+
_vals = [val for val, _lbl in col.domain_values]
|
|
1084
|
+
_checked = next((v for v in _vals
|
|
1085
|
+
if (v or "").upper() in _truthy), _vals[0])
|
|
1086
|
+
_unchecked = next((v for v in _vals if v != _checked),
|
|
1087
|
+
_vals[-1])
|
|
1088
|
+
settings_block = (
|
|
1089
|
+
f"{inner_indent}settings {{\n"
|
|
1090
|
+
f"{deep_indent}useDefaults: false\n"
|
|
1091
|
+
f"{deep_indent}checkedValue: {_checked}\n"
|
|
1092
|
+
f"{deep_indent}uncheckedValue: {_unchecked}\n"
|
|
1093
|
+
f"{inner_indent}}}\n"
|
|
1094
|
+
)
|
|
1095
|
+
new_block = _insert_block_in_order(
|
|
1096
|
+
new_block, "settings", settings_block)
|
|
1097
|
+
changes.append(Change(blk, "settings", None,
|
|
1098
|
+
f"checked={_checked}/unchecked={_unchecked}"))
|
|
1099
|
+
else:
|
|
1100
|
+
# domínio não-booleano (≠ 2 valores) não cabe num single
|
|
1101
|
+
# checkbox — deixa selectList/radioGroup tratar via lov.
|
|
1102
|
+
skipped.append((blk.name,
|
|
1103
|
+
f"checkbox requer domínio booleano (2 valores); "
|
|
1104
|
+
f"a coluna tem {len(col.domain_values)} — "
|
|
1105
|
+
f"considere FORM_ITEM_TYPE=selectList"))
|
|
1106
|
+
|
|
1107
|
+
# Self-heal stray `appearance { height: N }`: it is only valid on
|
|
1108
|
+
# multi-row types (textarea / editors). On any other pageItem it is
|
|
1109
|
+
# INVALID_PROPERTY at import and aborts the whole app import. Run
|
|
1110
|
+
# this regardless of whether a retype happened THIS run, so files a
|
|
1111
|
+
# previous rebuild left with e.g. a radioGroup + height:1 self-heal
|
|
1112
|
+
# on the next pass (the retype branch above won't re-fire once the
|
|
1113
|
+
# type already matches).
|
|
1114
|
+
if (blk.kind == "pageItem" and block_type
|
|
1115
|
+
and block_type not in HEIGHT_ITEM_TYPES):
|
|
1116
|
+
new_block, removed_h = _remove_nested_kv(
|
|
1117
|
+
new_block, "appearance", "height")
|
|
1118
|
+
if removed_h is not None:
|
|
1119
|
+
changes.append(Change(blk, "height", removed_h, None))
|
|
1120
|
+
|
|
1121
|
+
# Self-heal NULL option on closed-domain LOV items: a CHECK domain is
|
|
1122
|
+
# a closed set (NULL is never a member), so a selectList/radioGroup/etc.
|
|
1123
|
+
# over it must carry `lov { displayNullValue: false }`. Without it APEX
|
|
1124
|
+
# shows an empty entry that can't be saved on a NOT NULL flag. Runs
|
|
1125
|
+
# regardless of retype, so items a previous rebuild synthesized without
|
|
1126
|
+
# it (or hand-built static LOVs) self-heal on the next pass.
|
|
1127
|
+
if (blk.kind == "pageItem" and block_type in LOV_ITEM_TYPES
|
|
1128
|
+
and col.domain_values):
|
|
1129
|
+
has_static = re.search(
|
|
1130
|
+
r"^[ \t]*lov\s*\{[^}]*staticValues",
|
|
1131
|
+
new_block, re.MULTILINE | re.DOTALL)
|
|
1132
|
+
has_null_kv = re.search(
|
|
1133
|
+
r"^[ \t]*lov\s*\{.*?displayNullValue",
|
|
1134
|
+
new_block, re.MULTILINE | re.DOTALL)
|
|
1135
|
+
if has_static and not has_null_kv:
|
|
1136
|
+
new_block = _set_or_add_nested_kv(
|
|
1137
|
+
new_block, "lov", "displayNullValue", "false",
|
|
1138
|
+
inner_indent, deep_indent)
|
|
1139
|
+
changes.append(Change(blk, "displayNullValue", None, "false"))
|
|
1140
|
+
|
|
1141
|
+
# Default do item de formulário: form_default (regra/explícito, usado
|
|
1142
|
+
# como-está) > data_default do banco filtrado a literais. Só em itens
|
|
1143
|
+
# editáveis (não hidden/displayOnly/RO/PK — defaults ali não fazem
|
|
1144
|
+
# sentido ou são server-side). Idempotente: só escreve se mudou.
|
|
1145
|
+
if (blk.kind == "pageItem" and not effective_hidden
|
|
1146
|
+
and not col.read_only and not col.is_pk
|
|
1147
|
+
and block_type not in ("hidden", "displayOnly")):
|
|
1148
|
+
eff_default = ((col.form_default or "").strip() or None) \
|
|
1149
|
+
or _safe_default(col.data_default)
|
|
1150
|
+
if eff_default is not None:
|
|
1151
|
+
sv = (eff_default if re.match(r"^[A-Za-z0-9_.\-]+$", eff_default)
|
|
1152
|
+
else _quote_apexlang(eff_default))
|
|
1153
|
+
cur_default = None
|
|
1154
|
+
dm = re.search(r"^([ \t]*)default\s*\{.*?^\1\}",
|
|
1155
|
+
new_block, re.MULTILINE | re.DOTALL)
|
|
1156
|
+
if dm:
|
|
1157
|
+
sm = _KV_RE("staticValue").search(dm.group(0))
|
|
1158
|
+
cur_default = sm.group(2).strip() if sm else None
|
|
1159
|
+
if cur_default != sv:
|
|
1160
|
+
new_block = _set_or_add_nested_kv(
|
|
1161
|
+
new_block, "default", "type", "static",
|
|
1162
|
+
inner_indent, deep_indent)
|
|
1163
|
+
new_block = _set_or_add_nested_kv(
|
|
1164
|
+
new_block, "default", "staticValue", sv,
|
|
1165
|
+
inner_indent, deep_indent)
|
|
1166
|
+
changes.append(Change(blk, "default", cur_default, sv))
|
|
1167
|
+
|
|
1168
|
+
if blk.kind != "displayColumn" and col.label:
|
|
1169
|
+
new_label = _quote_apexlang(col.label)
|
|
1170
|
+
if blk.kind == "pageItem":
|
|
1171
|
+
if effective_hidden:
|
|
1172
|
+
pass # label is INVALID_PROPERTY on hidden pageItems
|
|
1173
|
+
else:
|
|
1174
|
+
# APEXLang has TWO label forms: the scalar `label: X`
|
|
1175
|
+
# and a `label { label: X; alignment: ... }` block
|
|
1176
|
+
# (used whenever the label carries extra attributes —
|
|
1177
|
+
# 665 alignment-in-label occurrences in the samples).
|
|
1178
|
+
# Prefer the block when present; both forms on the same
|
|
1179
|
+
# item is INVALID_PROPERTY, so a stray scalar next to a
|
|
1180
|
+
# block is removed (self-repair).
|
|
1181
|
+
lbl_blk_re = re.compile(
|
|
1182
|
+
r"^([ \t]*)label\s*\{(.*?)^\1\}",
|
|
1183
|
+
re.MULTILINE | re.DOTALL,
|
|
1184
|
+
)
|
|
1185
|
+
bm = lbl_blk_re.search(new_block)
|
|
1186
|
+
scalar_m = _top_kv_match(new_block, "label", inner_indent)
|
|
1187
|
+
if bm and scalar_m:
|
|
1188
|
+
line_end = new_block.find("\n", scalar_m.end())
|
|
1189
|
+
line_end = (len(new_block) if line_end == -1
|
|
1190
|
+
else line_end + 1)
|
|
1191
|
+
changes.append(Change(
|
|
1192
|
+
blk, "label",
|
|
1193
|
+
scalar_m.group(2).strip(), "(dup scalar removed)"))
|
|
1194
|
+
new_block = (new_block[:scalar_m.start()]
|
|
1195
|
+
+ new_block[line_end:])
|
|
1196
|
+
bm = lbl_blk_re.search(new_block)
|
|
1197
|
+
scalar_m = None
|
|
1198
|
+
if bm:
|
|
1199
|
+
kv = _KV_RE("label").search(bm.group(2))
|
|
1200
|
+
old_label = kv.group(2).strip() if kv else None
|
|
1201
|
+
if old_label != new_label:
|
|
1202
|
+
new_block = _set_or_add_nested_kv(
|
|
1203
|
+
new_block, "label", "label", new_label,
|
|
1204
|
+
inner_indent, deep_indent,
|
|
1205
|
+
)
|
|
1206
|
+
changes.append(
|
|
1207
|
+
Change(blk, "label", old_label, new_label))
|
|
1208
|
+
else:
|
|
1209
|
+
old_label = (scalar_m.group(2).strip()
|
|
1210
|
+
if scalar_m else None)
|
|
1211
|
+
if old_label != new_label:
|
|
1212
|
+
if scalar_m:
|
|
1213
|
+
new_block = (
|
|
1214
|
+
new_block[:scalar_m.start()]
|
|
1215
|
+
+ f"{inner_indent}label: {new_label}"
|
|
1216
|
+
+ new_block[scalar_m.end():]
|
|
1217
|
+
)
|
|
1218
|
+
elif type_match:
|
|
1219
|
+
# samples order scalars `type:` then
|
|
1220
|
+
# `label:` — insert after the type line
|
|
1221
|
+
le = new_block.find("\n", type_match.end())
|
|
1222
|
+
at = len(new_block) if le == -1 else le + 1
|
|
1223
|
+
new_block = (
|
|
1224
|
+
new_block[:at]
|
|
1225
|
+
+ f"{inner_indent}label: {new_label}\n"
|
|
1226
|
+
+ new_block[at:]
|
|
1227
|
+
)
|
|
1228
|
+
else:
|
|
1229
|
+
new_block = _set_top_kv(
|
|
1230
|
+
new_block, "label", new_label,
|
|
1231
|
+
inner_indent)
|
|
1232
|
+
changes.append(
|
|
1233
|
+
Change(blk, "label", old_label, new_label))
|
|
1234
|
+
else: # column / displayColumn — heading is nested
|
|
1235
|
+
inner = re.search(
|
|
1236
|
+
r"^([ \t]*)heading\s*\{(.*?)^\1\}",
|
|
1237
|
+
new_block, re.MULTILINE | re.DOTALL,
|
|
1238
|
+
)
|
|
1239
|
+
old_heading_val = None
|
|
1240
|
+
if inner:
|
|
1241
|
+
kv = _KV_RE("heading").search(inner.group(2))
|
|
1242
|
+
if kv:
|
|
1243
|
+
old_heading_val = kv.group(2).strip()
|
|
1244
|
+
if old_heading_val != new_label:
|
|
1245
|
+
new_block = _set_or_add_nested_kv(
|
|
1246
|
+
new_block, "heading", "heading", new_label,
|
|
1247
|
+
inner_indent, deep_indent,
|
|
1248
|
+
)
|
|
1249
|
+
changes.append(Change(blk, "heading", old_heading_val, new_label))
|
|
1250
|
+
|
|
1251
|
+
# ── help text (pageItem only) ──────────────────────────────────
|
|
1252
|
+
# BAU.DESCRIPTION → `help { helpText: ... }`. The value is a free
|
|
1253
|
+
# single-line token in APEXLang, so multi-line descriptions are
|
|
1254
|
+
# collapsed. IR/IG `column` blocks have no help property.
|
|
1255
|
+
if (blk.kind == "pageItem" and col.description
|
|
1256
|
+
and not effective_hidden):
|
|
1257
|
+
help_text = " ".join(col.description.split())
|
|
1258
|
+
inner_help = re.search(
|
|
1259
|
+
r"^([ \t]*)help\s*\{(.*?)^\1\}",
|
|
1260
|
+
new_block, re.MULTILINE | re.DOTALL,
|
|
1261
|
+
)
|
|
1262
|
+
old_help = None
|
|
1263
|
+
if inner_help:
|
|
1264
|
+
kv = _KV_RE("helpText").search(inner_help.group(2))
|
|
1265
|
+
if kv:
|
|
1266
|
+
old_help = kv.group(2).strip()
|
|
1267
|
+
if old_help != help_text:
|
|
1268
|
+
new_block = _set_or_add_nested_kv(
|
|
1269
|
+
new_block, "help", "helpText", help_text,
|
|
1270
|
+
inner_indent, deep_indent,
|
|
1271
|
+
)
|
|
1272
|
+
changes.append(Change(blk, "helpText", old_help, help_text))
|
|
1273
|
+
|
|
1274
|
+
# ── text case ──────────────────────────────────────────────────
|
|
1275
|
+
# BAU.TEXT_CASE U/L → `settings { textCase: upper|lower }`. Only
|
|
1276
|
+
# `type: textField` blocks carry the property in the samples;
|
|
1277
|
+
# other item types reject it.
|
|
1278
|
+
if (blk.kind != "displayColumn" and not effective_hidden
|
|
1279
|
+
and col.text_case in TEXT_CASE_MAP
|
|
1280
|
+
and block_type == "textField"):
|
|
1281
|
+
mapped_case = TEXT_CASE_MAP[col.text_case]
|
|
1282
|
+
inner_settings = re.search(
|
|
1283
|
+
r"^([ \t]*)settings\s*\{(.*?)^\1\}",
|
|
1284
|
+
new_block, re.MULTILINE | re.DOTALL,
|
|
1285
|
+
)
|
|
1286
|
+
old_case = None
|
|
1287
|
+
if inner_settings:
|
|
1288
|
+
kv = _KV_RE("textCase").search(inner_settings.group(2))
|
|
1289
|
+
if kv:
|
|
1290
|
+
old_case = kv.group(2).strip()
|
|
1291
|
+
if old_case != mapped_case:
|
|
1292
|
+
new_block = _set_or_add_nested_kv(
|
|
1293
|
+
new_block, "settings", "textCase", mapped_case,
|
|
1294
|
+
inner_indent, deep_indent,
|
|
1295
|
+
)
|
|
1296
|
+
changes.append(Change(blk, "textCase", old_case, mapped_case))
|
|
1297
|
+
|
|
1298
|
+
# ── LOV-rendered column/item → display is a STRING ─────────────
|
|
1299
|
+
# A column/item shown through an LOV (e.g. an FK *_ID rendered as the
|
|
1300
|
+
# referenced NAME, or a select list) displays a lookup string. BAU's
|
|
1301
|
+
# metadata describes the underlying numeric ID (center align + a
|
|
1302
|
+
# numeric mask like 000000), which is wrong for a string. Detect the
|
|
1303
|
+
# `lov { }` block (present for IR LOV columns; synthesized above for
|
|
1304
|
+
# retyped select lists) and: drop the numeric mask + force left align.
|
|
1305
|
+
# `lov { }` block → unambiguous string display. A SQL-derived
|
|
1306
|
+
# projection (subquery/function aliased to the column) is treated as a
|
|
1307
|
+
# string display ONLY when the column looks like a numeric ID — the
|
|
1308
|
+
# "ID shown as the looked-up name" case the numeric-ID metadata
|
|
1309
|
+
# (center align + 0000 mask) is wrong for. ID signature: FK/PK flags
|
|
1310
|
+
# (often unset when constraints aren't synced), OR center align, OR an
|
|
1311
|
+
# all-zeros mask, OR an ID-style name (ID / *_ID / ID_*). This avoids
|
|
1312
|
+
# touching a derived NUMERIC measure (e.g. `(qtd*preco) VALOR`, right
|
|
1313
|
+
# align + decimal mask), which matches none of these.
|
|
1314
|
+
_id_like = (col.is_fk or col.is_pk
|
|
1315
|
+
or (col.align_report or "").upper() == "C"
|
|
1316
|
+
or bool(re.fullmatch(r"0+", col.format_mask or ""))
|
|
1317
|
+
or bool(re.search(r"(^ID$|_ID$|^ID_)", blk.name.upper())))
|
|
1318
|
+
block_has_lov = bool(
|
|
1319
|
+
re.search(r"^[ \t]*lov\s*\{", new_block, re.MULTILINE)) \
|
|
1320
|
+
or (blk.name.upper() in derived_cols and _id_like)
|
|
1321
|
+
if (blk.kind != "displayColumn" and block_has_lov
|
|
1322
|
+
and not effective_hidden):
|
|
1323
|
+
stripped, removed = _remove_nested_kv(
|
|
1324
|
+
new_block, "appearance", "formatMask")
|
|
1325
|
+
if removed is not None:
|
|
1326
|
+
new_block = stripped
|
|
1327
|
+
changes.append(Change(blk, "formatMask", removed, None))
|
|
1328
|
+
|
|
1329
|
+
# ── format mask ────────────────────────────────────────────────
|
|
1330
|
+
# Skipped on hidden blocks — APEX 26 raises INVALID_PROPERTY — and on
|
|
1331
|
+
# LOV columns (the displayed value is a string, not the numeric ID).
|
|
1332
|
+
if (blk.kind != "displayColumn" and col.format_mask
|
|
1333
|
+
and not effective_hidden and not block_has_lov):
|
|
1334
|
+
old_mask_m = re.search(
|
|
1335
|
+
r"^[ \t]*formatMask\s*:\s*(.*?)$", new_block, re.MULTILINE
|
|
1336
|
+
)
|
|
1337
|
+
old_mask = old_mask_m.group(1).strip() if old_mask_m else None
|
|
1338
|
+
if old_mask != col.format_mask:
|
|
1339
|
+
new_block = _set_or_add_nested_kv(
|
|
1340
|
+
new_block, "appearance", "formatMask", col.format_mask,
|
|
1341
|
+
inner_indent, deep_indent,
|
|
1342
|
+
)
|
|
1343
|
+
changes.append(Change(blk, "formatMask", old_mask, col.format_mask))
|
|
1344
|
+
|
|
1345
|
+
# ── alignment ──────────────────────────────────────────────────
|
|
1346
|
+
# Also skipped on hidden blocks (same parser rule as formatMask).
|
|
1347
|
+
# pageItem and column use DIFFERENT keys and vocabularies — see
|
|
1348
|
+
# ALIGN_MAP / ALIGN_MAP_ITEM.
|
|
1349
|
+
if blk.kind == "pageItem":
|
|
1350
|
+
align_src, align_key, amap = col.align_form, "alignment", ALIGN_MAP_ITEM
|
|
1351
|
+
else:
|
|
1352
|
+
align_src, align_key, amap = col.align_report, "columnAlignment", ALIGN_MAP
|
|
1353
|
+
# LOV columns show a string → left-align, overriding BAU's numeric-ID
|
|
1354
|
+
# center default (see the LOV/format-mask note above).
|
|
1355
|
+
if block_has_lov:
|
|
1356
|
+
align_src = "L"
|
|
1357
|
+
if (blk.kind != "displayColumn" and align_src in amap
|
|
1358
|
+
and not effective_hidden):
|
|
1359
|
+
mapped = amap[align_src]
|
|
1360
|
+
old_align_m = re.search(
|
|
1361
|
+
rf"^[ \t]*{align_key}\s*:\s*(.*?)$", new_block, re.MULTILINE
|
|
1362
|
+
)
|
|
1363
|
+
old_align = old_align_m.group(1).strip() if old_align_m else None
|
|
1364
|
+
# `start` is the IR column default: APEX's import round-trip
|
|
1365
|
+
# drops an explicit `columnAlignment: start`, so writing it
|
|
1366
|
+
# would re-surface as phantom drift on every following run.
|
|
1367
|
+
if (blk.kind == "column" and old_align is None
|
|
1368
|
+
and mapped == "start"):
|
|
1369
|
+
pass
|
|
1370
|
+
elif old_align != mapped:
|
|
1371
|
+
new_block = _set_or_add_nested_kv(
|
|
1372
|
+
new_block, "layout", align_key, mapped,
|
|
1373
|
+
inner_indent, deep_indent,
|
|
1374
|
+
)
|
|
1375
|
+
changes.append(Change(blk, align_key, old_align, mapped))
|
|
1376
|
+
|
|
1377
|
+
# ── hidden / readOnly ──────────────────────────────────────────
|
|
1378
|
+
# `_set_top_kv` edits the block's OWN `type:` (depth-filtered), not
|
|
1379
|
+
# the nested `source { type: ... }`.
|
|
1380
|
+
if blk.kind != "displayColumn" and col.hidden:
|
|
1381
|
+
if block_type != "hidden":
|
|
1382
|
+
new_block = _set_top_kv(new_block, "type", "hidden",
|
|
1383
|
+
inner_indent)
|
|
1384
|
+
changes.append(Change(blk, "type", block_type, "hidden"))
|
|
1385
|
+
elif col.read_only and blk.kind not in ("pageItem", "displayColumn"):
|
|
1386
|
+
if block_type not in (None, "displayOnly"):
|
|
1387
|
+
# leave non-displayOnly types alone (e.g. textField with explicit RO)
|
|
1388
|
+
pass
|
|
1389
|
+
elif block_type != "displayOnly":
|
|
1390
|
+
new_block = _set_top_kv(new_block, "type", "displayOnly",
|
|
1391
|
+
inner_indent)
|
|
1392
|
+
changes.append(Change(blk, "type", block_type, "displayOnly"))
|
|
1393
|
+
|
|
1394
|
+
# ── sequence (block ordering) ──────────────────────────────────
|
|
1395
|
+
# BAU's REPORT_DISPLAY_ORDER / FORM_DISPLAY_ORDER are the FINAL
|
|
1396
|
+
# sequence values: PRC_BAU_DOCTAB_ORDER_COLUMNS already steps them
|
|
1397
|
+
# by 10 (10, 20, 30 …) precisely so manual insertions fit between.
|
|
1398
|
+
# Use them verbatim — multiplying again double-stepped the sequence
|
|
1399
|
+
# (e.g. 70 → 700). All three block kinds consume the stored value
|
|
1400
|
+
# directly (savedReport `displayColumn` always did).
|
|
1401
|
+
target_seq = None
|
|
1402
|
+
if blk.kind == "pageItem" and col.form_display_order is not None:
|
|
1403
|
+
target_seq = col.form_display_order
|
|
1404
|
+
elif blk.kind == "column" and col.report_display_order is not None:
|
|
1405
|
+
target_seq = col.report_display_order
|
|
1406
|
+
elif blk.kind == "displayColumn" and col.report_display_order is not None:
|
|
1407
|
+
target_seq = col.report_display_order
|
|
1408
|
+
|
|
1409
|
+
if target_seq is not None:
|
|
1410
|
+
if blk.kind == "displayColumn":
|
|
1411
|
+
# `sequence: N` directly inside the displayColumn block
|
|
1412
|
+
seq_m = _KV_RE("sequence").search(new_block)
|
|
1413
|
+
old_seq = seq_m.group(2).strip() if seq_m else None
|
|
1414
|
+
target_str = str(target_seq)
|
|
1415
|
+
if old_seq != target_str:
|
|
1416
|
+
new_block = _set_or_add_kv(new_block, "sequence",
|
|
1417
|
+
target_str, inner_indent)
|
|
1418
|
+
changes.append(Change(blk, "sequence", old_seq, target_str))
|
|
1419
|
+
else:
|
|
1420
|
+
# layout { sequence: N }
|
|
1421
|
+
layout_re = re.compile(
|
|
1422
|
+
r"^([ \t]*)layout\s*\{(.*?)^\1\}",
|
|
1423
|
+
re.MULTILINE | re.DOTALL,
|
|
1424
|
+
)
|
|
1425
|
+
lm = layout_re.search(new_block)
|
|
1426
|
+
old_seq = None
|
|
1427
|
+
if lm:
|
|
1428
|
+
kv = _KV_RE("sequence").search(lm.group(2))
|
|
1429
|
+
if kv:
|
|
1430
|
+
old_seq = kv.group(2).strip()
|
|
1431
|
+
target_str = str(target_seq)
|
|
1432
|
+
if old_seq != target_str:
|
|
1433
|
+
new_block = _set_or_add_nested_kv(
|
|
1434
|
+
new_block, "layout", "sequence", target_str,
|
|
1435
|
+
inner_indent, deep_indent,
|
|
1436
|
+
)
|
|
1437
|
+
changes.append(Change(blk, "sequence", old_seq, target_str))
|
|
1438
|
+
|
|
1439
|
+
# Splice back into the buffer.
|
|
1440
|
+
buf = buf[:blk.start] + new_block + buf[blk.end:]
|
|
1441
|
+
|
|
1442
|
+
changes = list(reversed(changes))
|
|
1443
|
+
|
|
1444
|
+
# rowsPerPage dos IRs: seta todo `view { rowsPerPage: N }` do page para o
|
|
1445
|
+
# valor de config (só aparece em savedReport de interactiveReport). Feito
|
|
1446
|
+
# no buffer final (é region-level, fora dos blocos de coluna/item).
|
|
1447
|
+
if rows_per_page and rows_per_page > 0:
|
|
1448
|
+
rpp_blk = Block("savedReport", "view", "", 0, 0, "")
|
|
1449
|
+
|
|
1450
|
+
def _sub_rpp(m):
|
|
1451
|
+
if m.group(2) != str(rows_per_page):
|
|
1452
|
+
changes.append(Change(rpp_blk, "rowsPerPage",
|
|
1453
|
+
m.group(2), str(rows_per_page)))
|
|
1454
|
+
return f"{m.group(1)}{rows_per_page}"
|
|
1455
|
+
|
|
1456
|
+
buf = re.sub(r"(rowsPerPage:\s*)(\d+)", _sub_rpp, buf)
|
|
1457
|
+
|
|
1458
|
+
return RebuildResult(new_source=buf, changes=changes, skipped=skipped)
|