Skip to content

Commit 6e213c9

Browse files
author
Samson Gebre
committed
Tighten shape validation for filter methods to prevent silent drops of extra args; add tests for arity violations and ensure manual notes are correctly emitted.
1 parent 62d1659 commit 6e213c9

2 files changed

Lines changed: 306 additions & 13 deletions

File tree

src/PowerPlatform/Dataverse/migration/migrate_v0_to_v1.py

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,6 @@ def _arg(args: Sequence[cst.Arg], pos_index: int, *kwarg_names: str) -> Optional
258258
return _kwarg(args, *kwarg_names) if kwarg_names else None
259259

260260

261-
def _positional_count(args: Sequence[cst.Arg]) -> int:
262-
return sum(1 for a in args if a.keyword is None)
263-
264-
265261
# Canonical v0 kwarg names per filter method. Used by both the migrator and the
266262
# manual-review finder so they agree on what counts as "recognized arg shape".
267263
# These names come from the b10-era QueryBuilder.filter_* signatures and the
@@ -372,8 +368,14 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Bas
372368

373369
# ----------------------------------------------------------------
374370
# .filter_*(...) -> .where(col(...) ...)
371+
# Only rewrite when the args match the documented v0 shape exactly.
372+
# If they don't (e.g. extra positionals, unknown kwargs), leave the
373+
# call untouched -- the _ManualReviewFinder will emit a [MANUAL] note
374+
# so the user notices and rewrites it by hand. Without the shape gate
375+
# here, calls like .filter_raw('a', 'EXTRA') would be silently
376+
# rewritten to .where(raw('a')) with the 'EXTRA' arg dropped.
375377
# ----------------------------------------------------------------
376-
if method_name in _ALL_FILTER_METHODS:
378+
if method_name in _ALL_FILTER_METHODS and _can_extract_filter_method_args(method_name, updated_node.args):
377379
where_arg = self._build_filter_arg(method_name, updated_node.args)
378380
if where_arg is not None:
379381
return updated_node.with_changes(
@@ -748,9 +750,44 @@ def _append_aliases_preserving_layout(
748750
if not new_names:
749751
return existing
750752

753+
# Separator-comma template -- the comma+whitespace placed between *non-final*
754+
# aliases. For multi-alias inputs we copy existing[0].comma (which already
755+
# captures the original block's style). For single-alias inputs we still need
756+
# to detect a multi-line layout: ``from X import (\n raw,\n)`` has only
757+
# one alias but its trailing comma carries the newline trivia, so we copy
758+
# *that* as the separator. Truly inline single-alias imports (``import raw``)
759+
# have ``MaybeSentinel.DEFAULT`` for the comma -- fall back to single-space.
760+
separator_template: Optional[cst.Comma] = None
751761
if len(existing) >= 2 and isinstance(existing[0].comma, cst.Comma):
752762
separator_template = existing[0].comma
753-
else:
763+
elif len(existing) == 1 and isinstance(existing[0].comma, cst.Comma):
764+
# Single-alias multi-line case: ``from X import (\n raw,\n)`` has only
765+
# one alias, so existing[0].comma is the *trailing* comma whose
766+
# whitespace_after carries only ``\n`` (the closing paren sits at col 0
767+
# with no indent). That bare ``\n`` is not a usable separator -- using
768+
# it would put ``col`` flush against the left margin. Detect the
769+
# multi-line shape and synthesize a separator that re-indents to a
770+
# 4-space PEP 8 default (matches what black would produce for the
771+
# multi-alias form).
772+
sole = existing[0].comma
773+
ws = sole.whitespace_after
774+
is_multiline = False
775+
if isinstance(ws, cst.SimpleWhitespace) and "\n" in ws.value:
776+
is_multiline = True
777+
elif isinstance(ws, cst.ParenthesizedWhitespace):
778+
# ParenthesizedWhitespace always implies multi-line (it owns at least
779+
# one newline) -- safe to treat as the multi-line case.
780+
is_multiline = True
781+
if is_multiline:
782+
separator_template = cst.Comma(
783+
whitespace_after=cst.ParenthesizedWhitespace(
784+
first_line=cst.TrailingWhitespace(),
785+
empty_lines=[],
786+
indent=True,
787+
last_line=cst.SimpleWhitespace(" "),
788+
)
789+
)
790+
if separator_template is None:
754791
separator_template = cst.Comma(whitespace_after=cst.SimpleWhitespace(" "))
755792
trailing_template = existing[-1].comma
756793

@@ -797,18 +834,42 @@ def _can_extract_filter_method_args(method_name: str, args: Sequence[cst.Arg]) -
797834
that the migrator and the manual-review finder agree on what counts as a
798835
rewriteable call. Used by the finder to emit a [MANUAL] note for any
799836
.filter_*() call shape the migrator cannot rewrite (e.g. unrecognized kwargs).
837+
838+
Also rejects calls that contain *extra* args beyond the documented shape.
839+
Previously a call like ``.filter_raw('a eq 1', 'EXTRA')`` returned True
840+
(because position 0 was present) and the migrator silently dropped the
841+
extra arg in the rewrite -- losing real user code. By insisting on exact
842+
arity here, malformed calls fall into the ``[MANUAL]`` path instead of
843+
being miscompiled.
800844
"""
801845
if method_name not in _ALL_FILTER_METHODS:
802846
return False
803847
col_name, val_name, extra_name = _FILTER_KWARGS.get(method_name, ("column", "value", None))
804848
if _arg(args, 0, col_name) is None:
805849
return False
850+
851+
# Determine the exact expected arity for this method.
806852
if method_name == "filter_raw" or method_name in _UNARY_FILTER_MAP:
807-
return True
808-
if val_name is None or _arg(args, 1, val_name) is None:
853+
expected_arity = 1
854+
allowed_kwargs = {col_name}
855+
elif method_name in ("filter_between", "filter_not_between"):
856+
if val_name is None or _arg(args, 1, val_name) is None:
857+
return False
858+
if extra_name is None or _arg(args, 2, extra_name) is None:
859+
return False
860+
expected_arity = 3
861+
allowed_kwargs = {col_name, val_name, extra_name}
862+
else:
863+
if val_name is None or _arg(args, 1, val_name) is None:
864+
return False
865+
expected_arity = 2
866+
allowed_kwargs = {col_name, val_name}
867+
868+
if len(args) != expected_arity:
809869
return False
810-
if method_name in ("filter_between", "filter_not_between"):
811-
return extra_name is not None and _arg(args, 2, extra_name) is not None
870+
for a in args:
871+
if isinstance(a.keyword, cst.Name) and a.keyword.value not in allowed_kwargs:
872+
return False
812873
return True
813874

814875

@@ -1048,8 +1109,20 @@ def visit_For(self, node: cst.For) -> None:
10481109
return
10491110
if not (isinstance(inner.attr, cst.Name) and inner.attr.value == "records"):
10501111
return
1051-
# The outer iter is some .records.get(...) call. Match only when the loop
1052-
# target is a single Name so we can compare with the inner For's iter.
1112+
# Require the .records receiver to actually be the configured client
1113+
# variable (or a known alias of it). Otherwise an unrelated
1114+
# ``database_pool.records.get(...)`` would be flagged with a note that
1115+
# advises rewriting to ``client.records.list(...)`` -- replacing the
1116+
# wrong receiver name. Comparing to _known_client_names (rather than
1117+
# only _client_var) lets the check work when the user has aliased
1118+
# the client (handled by _flag_non_default_client_assignment above).
1119+
receiver = inner.value
1120+
if not isinstance(receiver, cst.Name):
1121+
return
1122+
if receiver.value not in self._known_client_names:
1123+
return
1124+
# The outer iter is some <client>.records.get(...) call. Match only when
1125+
# the loop target is a single Name so we can compare with the inner For's iter.
10531126
if not isinstance(node.target, cst.Name):
10541127
return
10551128
outer_var = node.target.value
@@ -1274,7 +1347,15 @@ def migrate_file(path: Path, *, dry_run: bool = False, client_var: str = "client
12741347
except ValueError as exc:
12751348
print(f" [SKIP] {path}: {exc}", file=sys.stderr)
12761349
return False, False, []
1277-
raw_notes = find_manual_patterns(original, client_var=client_var)
1350+
# Run the manual-pattern finder against the *migrated* source so the
1351+
# reported <line>:<col> coordinates match the file the user is now looking
1352+
# at on disk. Running it against ``original`` produced stale coordinates
1353+
# whenever the codemod inserted a new import (or otherwise shifted line
1354+
# numbers), so jumping from a CI/editor message landed on the wrong line.
1355+
# Calls that the codemod successfully rewrote no longer match the v0 finder
1356+
# in ``migrated`` -- that is the desired behavior: a rewritten call should
1357+
# not also produce a [MANUAL] note about its v0 shape.
1358+
raw_notes = find_manual_patterns(migrated, client_var=client_var)
12781359
manual = [f"{path}:{note}" for note in raw_notes]
12791360
changed = migrated != original
12801361
if changed and dry_run:

0 commit comments

Comments
 (0)