pythia-plsql 0.8.0__tar.gz → 0.9.0__tar.gz
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.
- {pythia_plsql-0.8.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.9.0}/PKG-INFO +2 -2
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/README.md +1 -1
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/pyproject.toml +1 -1
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia.py +296 -34
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0/scripts/pythia_plsql.egg-info}/PKG-INFO +2 -2
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-apply/SKILL.md +13 -13
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_phase3.py +209 -3
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_phase5.py +2 -1
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/LICENSE +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/compile-errors.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/dependencies.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/impact.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/invalid-objects.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/name-occupants.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/object-names.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/object-source.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/plscope-enabled.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/plscope-statements.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/plscope-usages.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/session-privileges.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/similar-candidates.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/source.sql +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/setup.cfg +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-conventions/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-explore/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-impact/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-review/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-review/reference/antipatterns.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-setup/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-skill-author/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-spec/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-write/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-write/reference/patterns.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/using-pythia/SKILL.md +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_install.py +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_phase1.py +0 -0
- {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_phase2.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythia-plsql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/thaildhe172591/pythia
|
|
@@ -211,7 +211,7 @@ Per-group write policy, `.pythia/policy.json` (defaults shown):
|
|
|
211
211
|
| Group | Default | Is rollback real? |
|
|
212
212
|
|---|---|---|
|
|
213
213
|
| `plsql_source` | `confirm` | **Yes — completely.** Source is recoverable from `ALL_SOURCE`. |
|
|
214
|
-
| `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. |
|
|
214
|
+
| `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. Revalidation checks the row set *before* the write; it is not an undo. |
|
|
215
215
|
| `structural` | `deny` | **Almost never.** `DROP COLUMN` is permanent; a dropped table may be in the Recycle Bin. |
|
|
216
216
|
| `grants` | `deny` | Yes, but by hand. |
|
|
217
217
|
| `session` | `allow` | Not needed. |
|
|
@@ -197,7 +197,7 @@ Per-group write policy, `.pythia/policy.json` (defaults shown):
|
|
|
197
197
|
| Group | Default | Is rollback real? |
|
|
198
198
|
|---|---|---|
|
|
199
199
|
| `plsql_source` | `confirm` | **Yes — completely.** Source is recoverable from `ALL_SOURCE`. |
|
|
200
|
-
| `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. |
|
|
200
|
+
| `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. Revalidation checks the row set *before* the write; it is not an undo. |
|
|
201
201
|
| `structural` | `deny` | **Almost never.** `DROP COLUMN` is permanent; a dropped table may be in the Recycle Bin. |
|
|
202
202
|
| `grants` | `deny` | Yes, but by hand. |
|
|
203
203
|
| `session` | `allow` | Not needed. |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pythia-plsql"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -798,6 +798,165 @@ def prepare_statement(sql, group):
|
|
|
798
798
|
return text
|
|
799
799
|
|
|
800
800
|
|
|
801
|
+
REVALIDATE_REFUSAL = """\
|
|
802
|
+
Refused: pythia cannot work out which rows this statement would touch, because
|
|
803
|
+
%s.
|
|
804
|
+
A data_dml write is approved on the rows it affects, so a row set that cannot
|
|
805
|
+
be measured is refused rather than guessed at.
|
|
806
|
+
Rewrite it as one UPDATE or DELETE over one table with one WHERE."""
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def mask_literals(sql):
|
|
810
|
+
"""A same-length copy with quoted text and comments blanked to spaces.
|
|
811
|
+
Length is the contract: an index found in the mask points at the same
|
|
812
|
+
character in the original, which is what lets dml_probe trust a keyword
|
|
813
|
+
scan without carrying a real parser."""
|
|
814
|
+
out, i, n = [], 0, len(sql)
|
|
815
|
+
while i < n:
|
|
816
|
+
if sql[i] == "'":
|
|
817
|
+
out.append(" ")
|
|
818
|
+
i += 1
|
|
819
|
+
while i < n: # a doubled '' simply closes and
|
|
820
|
+
out.append(" ") # reopens — blanked either way
|
|
821
|
+
i += 1
|
|
822
|
+
if sql[i - 1] == "'":
|
|
823
|
+
break
|
|
824
|
+
elif sql.startswith("--", i):
|
|
825
|
+
while i < n and sql[i] != "\n":
|
|
826
|
+
out.append(" ")
|
|
827
|
+
i += 1
|
|
828
|
+
elif sql.startswith("/*", i):
|
|
829
|
+
end = sql.find("*/", i + 2)
|
|
830
|
+
end = n if end < 0 else end + 2
|
|
831
|
+
out.append(" " * (end - i))
|
|
832
|
+
i = end
|
|
833
|
+
else:
|
|
834
|
+
out.append(sql[i])
|
|
835
|
+
i += 1
|
|
836
|
+
return "".join(out)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def dml_probe(stmt):
|
|
840
|
+
"""(verb, target, predicate|None): the SELECT-able description of the rows
|
|
841
|
+
an UPDATE or DELETE will touch. None for INSERT — nothing exists
|
|
842
|
+
beforehand, so there is nothing to revalidate. Everything else is refused,
|
|
843
|
+
for classify()'s reason: a parser that guesses generously is a parser that
|
|
844
|
+
lets deny be bypassed.
|
|
845
|
+
|
|
846
|
+
target is a span of text, not a parsed identifier, so a schema qualifier
|
|
847
|
+
and an alias survive and a predicate written against the alias resolves."""
|
|
848
|
+
s = skip_leading_noise(stmt)
|
|
849
|
+
m = re.match(r"(insert|update|delete|merge)\b", s, re.I)
|
|
850
|
+
verb = m.group(1).lower() if m else ""
|
|
851
|
+
if verb == "insert":
|
|
852
|
+
return None
|
|
853
|
+
if verb == "merge":
|
|
854
|
+
sys.exit("Refused: a MERGE cannot be revalidated — the rows it touches "
|
|
855
|
+
"are decided by a join against its source query, so pythia "
|
|
856
|
+
"cannot show you the set you would be approving.\n"
|
|
857
|
+
"Express it as an UPDATE and/or a DELETE, or run it by hand "
|
|
858
|
+
"with a DBA watching.")
|
|
859
|
+
if re.search(r"q'", s, re.I):
|
|
860
|
+
sys.exit(REVALIDATE_REFUSAL % "it uses an alternative-quoted (q'...') "
|
|
861
|
+
"literal, which this parser does not read")
|
|
862
|
+
masked = mask_literals(s)
|
|
863
|
+
if re.search(r"\bselect\b", masked, re.I):
|
|
864
|
+
sys.exit(REVALIDATE_REFUSAL % "it contains a subquery, so its row set "
|
|
865
|
+
"is not one predicate over one table")
|
|
866
|
+
wheres = [w.start() for w in re.finditer(r"\bwhere\b", masked, re.I)]
|
|
867
|
+
if len(wheres) > 1:
|
|
868
|
+
sys.exit(REVALIDATE_REFUSAL % "it has more than one WHERE, so which "
|
|
869
|
+
"one selects the rows would be a guess")
|
|
870
|
+
head = re.match(r"delete\s+(?:from\s+)?" if verb == "delete"
|
|
871
|
+
else r"update\s+", masked, re.I)
|
|
872
|
+
start = head.end()
|
|
873
|
+
if verb == "update":
|
|
874
|
+
sets = re.search(r"\bset\b", masked[start:], re.I)
|
|
875
|
+
if not sets:
|
|
876
|
+
sys.exit(REVALIDATE_REFUSAL % "the UPDATE has no SET clause")
|
|
877
|
+
end = start + sets.start()
|
|
878
|
+
else:
|
|
879
|
+
end = wheres[0] if wheres else len(masked)
|
|
880
|
+
if wheres and wheres[0] < end:
|
|
881
|
+
sys.exit(REVALIDATE_REFUSAL % "its WHERE comes before the table it "
|
|
882
|
+
"reads, which this parser does not expect")
|
|
883
|
+
target = s[start:end].strip()
|
|
884
|
+
if not target:
|
|
885
|
+
sys.exit(REVALIDATE_REFUSAL % "no table name follows the verb")
|
|
886
|
+
pred = s[wheres[0] + 5:].strip() if wheres else None
|
|
887
|
+
return verb, target, pred
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def count_sql(target, pred):
|
|
891
|
+
"""The fingerprint query. Interpolated, not bound: a table name and a
|
|
892
|
+
predicate are not bindable. The text comes from the very file whose DML is
|
|
893
|
+
about to run, so this opens no trust boundary that statement does not open
|
|
894
|
+
already — but it runs BEFORE approval, which SECURITY.md says out loud."""
|
|
895
|
+
return ("select count(*) n, sum(ora_hash(rowidtochar(rowid))) s, "
|
|
896
|
+
"min(ora_hash(rowidtochar(rowid))) lo, "
|
|
897
|
+
"max(ora_hash(rowidtochar(rowid))) hi from "
|
|
898
|
+
+ target + (f" where {pred}" if pred else ""))
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def sample_sql(target, pred):
|
|
902
|
+
return ("select * from " + target + (f" where {pred}" if pred else "")
|
|
903
|
+
+ " fetch first :n rows only")
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def measure_row_set(conn, target, pred, limit=10):
|
|
907
|
+
"""Count, fingerprint and sample the rows the statement will touch.
|
|
908
|
+
Failing to measure is a refusal, never a reason to proceed: a write
|
|
909
|
+
approved on a row set nobody could see is what this exists to prevent.
|
|
910
|
+
|
|
911
|
+
ponytail: ROWID tracks set membership, not row contents — an in-place
|
|
912
|
+
update to an approved row keeps its ROWID. Hash the sampled columns too if
|
|
913
|
+
content drift ever needs catching."""
|
|
914
|
+
try:
|
|
915
|
+
_, agg = run_query(conn, count_sql(target, pred))
|
|
916
|
+
cols, rows = run_query(conn, sample_sql(target, pred),
|
|
917
|
+
{"n": fetch_n(limit)})
|
|
918
|
+
except Exception as e: # the driver's exception class is imported
|
|
919
|
+
sys.exit("Refused: pythia could not measure the rows this " # in main()
|
|
920
|
+
f"statement would touch.\n {target} — {e}\n"
|
|
921
|
+
"Revalidation is mandatory for UPDATE and DELETE, so a set "
|
|
922
|
+
"that cannot be measured cannot be approved.")
|
|
923
|
+
n, s, lo, hi = (list(agg[0]) + [None] * 4)[:4] if agg else (0, 0, 0, 0)
|
|
924
|
+
return {"target": target, "predicate": pred or "", "count": int(n or 0),
|
|
925
|
+
"hash": f"{s or 0}/{lo or 0}/{hi or 0}",
|
|
926
|
+
"columns": list(cols),
|
|
927
|
+
"sample": [[cell(v) for v in r] for r in rows[:limit]],
|
|
928
|
+
"truncated": len(rows) > limit}
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def fingerprint_text(row_set):
|
|
932
|
+
"""What the token hashes. Empty when there is no row set, so plsql_source
|
|
933
|
+
tokens stay byte-identical to the ones 0.8.0 printed."""
|
|
934
|
+
if not row_set:
|
|
935
|
+
return ""
|
|
936
|
+
return f"rows={row_set['count']} hash={row_set['hash']}"
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def render_row_set(row_set, en=False, indent=" "):
|
|
940
|
+
"""The rows an approval is about — printed identically by the preview and
|
|
941
|
+
by approve, because what is approved must be what was shown."""
|
|
942
|
+
n = row_set["count"]
|
|
943
|
+
where = f" where {row_set['predicate']}" if row_set["predicate"] else ""
|
|
944
|
+
out = [f"{indent}{paint(str(n) + ' rows', 'bold', en)} in "
|
|
945
|
+
f"{row_set['target']}{where}"]
|
|
946
|
+
if n > 1000:
|
|
947
|
+
out.append(indent + paint("! more rows than anyone can read one by "
|
|
948
|
+
"one — the count and the fingerprint are "
|
|
949
|
+
"the whole of what you are approving",
|
|
950
|
+
"yellow", en))
|
|
951
|
+
if row_set["sample"]:
|
|
952
|
+
out.append(indent + "\t".join(row_set["columns"]))
|
|
953
|
+
out.extend(indent + "\t".join(r) for r in row_set["sample"])
|
|
954
|
+
if row_set["truncated"]:
|
|
955
|
+
out.append(f"{indent}-- truncated at {len(row_set['sample'])} of "
|
|
956
|
+
f"{n} rows")
|
|
957
|
+
return out
|
|
958
|
+
|
|
959
|
+
|
|
801
960
|
POLICY_DEFAULTS = {"plsql_source": "confirm", "data_dml": "deny",
|
|
802
961
|
"structural": "deny", "grants": "deny", "session": "allow"}
|
|
803
962
|
|
|
@@ -805,6 +964,7 @@ ROLLBACK_TABLE = """\
|
|
|
805
964
|
Is rollback real? (this table also appears in README.md and pythia-apply)
|
|
806
965
|
plsql_source Yes - completely. Source is recoverable from ALL_SOURCE.
|
|
807
966
|
data_dml No. After commit only Flashback Query remains, within undo retention.
|
|
967
|
+
Revalidation checks the row set before the write; it is not an undo.
|
|
808
968
|
structural Almost never. DROP COLUMN is permanent; a dropped table may be in the Recycle Bin.
|
|
809
969
|
grants Yes, but by hand.
|
|
810
970
|
session Not needed."""
|
|
@@ -969,9 +1129,11 @@ def grant_path(root, token):
|
|
|
969
1129
|
return grants_root(root) / f"{token}.json"
|
|
970
1130
|
|
|
971
1131
|
|
|
972
|
-
def mint_grant(root, token, conn_name, now=None):
|
|
1132
|
+
def mint_grant(root, token, conn_name, now=None, revalidate=None):
|
|
973
1133
|
"""Record one developer approval. Single use, short-lived, bound to the
|
|
974
|
-
connection the preview ran on.
|
|
1134
|
+
connection the preview ran on. `revalidate` is the row-set fingerprint the
|
|
1135
|
+
human was shown — audit data, not a second gate: the enforcement is the
|
|
1136
|
+
token, which already hashes that same fingerprint."""
|
|
975
1137
|
import datetime
|
|
976
1138
|
now = now or datetime.datetime.now()
|
|
977
1139
|
rec = {"token": token,
|
|
@@ -980,8 +1142,7 @@ def mint_grant(root, token, conn_name, now=None):
|
|
|
980
1142
|
"expires_at": (now + datetime.timedelta(
|
|
981
1143
|
minutes=GRANT_TTL_MINUTES)).isoformat(timespec="seconds"),
|
|
982
1144
|
"used_at": None,
|
|
983
|
-
|
|
984
|
-
"revalidate": None}
|
|
1145
|
+
"revalidate": revalidate or None}
|
|
985
1146
|
d = grants_root(root)
|
|
986
1147
|
d.mkdir(parents=True, exist_ok=True)
|
|
987
1148
|
grant_path(root, token).write_text(json.dumps(rec, indent=2) + "\n",
|
|
@@ -1798,7 +1959,13 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
1798
1959
|
extra = ("no snapshot can undo it after commit"
|
|
1799
1960
|
if group in ("data_dml", "structural", "grants")
|
|
1800
1961
|
else "policy forbids it")
|
|
1801
|
-
|
|
1962
|
+
note = ("\nRevalidation narrows that risk without removing it: the "
|
|
1963
|
+
"preview shows the rows and the count, apply refuses if that "
|
|
1964
|
+
"set moved, and the write is rolled back if the statement "
|
|
1965
|
+
"touches a different number of rows. What nothing here can do "
|
|
1966
|
+
"is undo a committed DELETE."
|
|
1967
|
+
if group == "data_dml" else "")
|
|
1968
|
+
sys.exit(f"Refused: {group} is set to deny — {extra}.{note}\n"
|
|
1802
1969
|
f"To allow it once you have weighed that: "
|
|
1803
1970
|
f"{invocation()} policy set {group} confirm")
|
|
1804
1971
|
stmt = prepare_statement(file_text, group)
|
|
@@ -1837,17 +2004,23 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
1837
2004
|
|
|
1838
2005
|
# 1. SNAPSHOT — before anything else, unconditionally.
|
|
1839
2006
|
db_source = ""
|
|
2007
|
+
row_set = None
|
|
1840
2008
|
if group == "plsql_source":
|
|
1841
2009
|
_, rows = run_query(conn, load_query("object-source.sql"),
|
|
1842
2010
|
{"s": schema, "n": name, "t": otype})
|
|
1843
2011
|
db_source = "".join(cell(r[0]) for r in rows)
|
|
1844
|
-
|
|
2012
|
+
elif group == "data_dml":
|
|
2013
|
+
# the row set IS the snapshot here: nothing can undo a committed DML,
|
|
2014
|
+
# so what the developer approves is the set, and the token carries it
|
|
2015
|
+
probe = dml_probe(stmt)
|
|
2016
|
+
if probe:
|
|
2017
|
+
row_set = measure_row_set(conn, probe[1], probe[2])
|
|
2018
|
+
token = apply_token(otype, name, file_text,
|
|
2019
|
+
db_source + fingerprint_text(row_set))
|
|
1845
2020
|
if ns.yes and not human_at_the_keyboard():
|
|
1846
2021
|
sys.exit(HEADLESS_YES_MSG)
|
|
1847
2022
|
if ns.confirm and ns.confirm != token:
|
|
1848
|
-
sys.exit(
|
|
1849
|
-
"database object changed since that preview. Preview again:\n"
|
|
1850
|
-
f" {invocation()} apply {ns.file}")
|
|
2023
|
+
sys.exit(token_mismatch_message(ns, row_set))
|
|
1851
2024
|
# The token proves the content did not move; the grant proves a human
|
|
1852
2025
|
# approved it. --yes is itself that human act, at a real console.
|
|
1853
2026
|
grant = None
|
|
@@ -1865,12 +2038,16 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
1865
2038
|
"token": token, "applied": False,
|
|
1866
2039
|
"confirmed_via": ("yes" if ns.yes else
|
|
1867
2040
|
"grant" if confirmed else "preview"),
|
|
2041
|
+
"row_set": row_set,
|
|
1868
2042
|
"grant_minted_at": (grant or {}).get("minted_at"),
|
|
1869
2043
|
"tty": human_at_the_keyboard(),
|
|
1870
2044
|
"invalid_before": invalid_before, **(origin or {})}
|
|
1871
2045
|
entry = write_journal_entry(ns.project_root, otype, name, db_source,
|
|
1872
2046
|
file_text, meta)
|
|
1873
|
-
created
|
|
2047
|
+
# "created" is about an OBJECT that did not exist. A DML/DDL statement has
|
|
2048
|
+
# no object identity, so it is never "new" — and saying so would promise a
|
|
2049
|
+
# DROP-shaped undo that does not exist for those groups.
|
|
2050
|
+
created = not db_source.strip() and group == "plsql_source"
|
|
1874
2051
|
|
|
1875
2052
|
# 2. IMPACT
|
|
1876
2053
|
summary = ""
|
|
@@ -1891,19 +2068,31 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
1891
2068
|
print(json.dumps({"ok": True, "object": name, "type": otype,
|
|
1892
2069
|
"created": created, "changed_lines": changed,
|
|
1893
2070
|
"summary": summary, "warning": warn,
|
|
2071
|
+
"row_set": row_set,
|
|
1894
2072
|
"naming_warning": style, "token": token,
|
|
1895
2073
|
"journal": entry, "will_apply": confirmed}))
|
|
1896
2074
|
else:
|
|
1897
2075
|
en = getattr(ns, "color", False)
|
|
1898
|
-
if
|
|
1899
|
-
|
|
1900
|
-
elif changed == 0:
|
|
1901
|
-
head = "no source change (recompile)"
|
|
2076
|
+
if group != "plsql_source":
|
|
2077
|
+
print(f"\n {paint(group, 'bold', en)} statement on {schema}")
|
|
1902
2078
|
else:
|
|
1903
|
-
|
|
1904
|
-
|
|
2079
|
+
if created:
|
|
2080
|
+
head = "new object"
|
|
2081
|
+
elif changed == 0:
|
|
2082
|
+
head = "no source change (recompile)"
|
|
2083
|
+
else:
|
|
2084
|
+
head = f"{changed} lines changed"
|
|
2085
|
+
print(f"\n {paint(f'{name} ({otype})', 'bold', en)} in {schema} "
|
|
2086
|
+
f"— {head}")
|
|
1905
2087
|
if summary:
|
|
1906
2088
|
print(f" {summary.lstrip('- ')}")
|
|
2089
|
+
if row_set is not None:
|
|
2090
|
+
print()
|
|
2091
|
+
for ln in render_row_set(row_set, en):
|
|
2092
|
+
print(ln)
|
|
2093
|
+
elif group == "data_dml":
|
|
2094
|
+
print("\n INSERT — no rows exist beforehand, so there is no row "
|
|
2095
|
+
"set to revalidate.")
|
|
1907
2096
|
if warn:
|
|
1908
2097
|
print(f"\n {paint(warn, 'yellow', en)}")
|
|
1909
2098
|
if style:
|
|
@@ -1914,7 +2103,9 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
1914
2103
|
print(f" {paint_diff_line(ln, en)}")
|
|
1915
2104
|
print(paint(f"\n Snapshot saved: {journal_root(ns.project_root) / entry}",
|
|
1916
2105
|
"dim", en))
|
|
1917
|
-
if not confirmed:
|
|
2106
|
+
if not confirmed and group == "plsql_source":
|
|
2107
|
+
# only this group has a restore.sql worth running: the others get a
|
|
2108
|
+
# generated DROP that nothing would accept
|
|
1918
2109
|
print(paint(" Rollback file for the current database version — "
|
|
1919
2110
|
"use it if this is\n run by hand instead: "
|
|
1920
2111
|
f"{journal_root(ns.project_root) / entry / 'restore.sql'}",
|
|
@@ -1940,6 +2131,17 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
1940
2131
|
"'IDENTIFIERS:ALL, STATEMENTS:ALL'")
|
|
1941
2132
|
with conn.cursor() as cur:
|
|
1942
2133
|
cur.execute(stmt)
|
|
2134
|
+
affected = cur.rowcount if group == "data_dml" else None
|
|
2135
|
+
if group == "data_dml":
|
|
2136
|
+
# the last-millisecond check: the probe ran before this statement, and
|
|
2137
|
+
# READ COMMITTED lets the world move in between
|
|
2138
|
+
if row_set is not None and affected != row_set["count"]:
|
|
2139
|
+
conn.rollback()
|
|
2140
|
+
sys.exit(f"Rolled back: the statement touched {affected} rows, but "
|
|
2141
|
+
f"{row_set['count']} were approved.\nNothing was "
|
|
2142
|
+
"committed — the rows moved between the check and the "
|
|
2143
|
+
"write. Preview again.")
|
|
2144
|
+
conn.commit() # DDL commits itself; DML never did until now
|
|
1943
2145
|
if grant is not None:
|
|
1944
2146
|
spend_grant(ns.project_root, token) # one approval, one write
|
|
1945
2147
|
|
|
@@ -1968,14 +2170,20 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
1968
2170
|
print(json.dumps({"ok": ok, "applied": True, "object": name,
|
|
1969
2171
|
"type": otype, "errors": [list(r) for r in own_errors],
|
|
1970
2172
|
"newly_invalid": [list(x) for x in broke],
|
|
1971
|
-
"
|
|
2173
|
+
"rows": affected,
|
|
2174
|
+
"restore": (f"{invocation()} journal restore {entry}"
|
|
2175
|
+
if group == "plsql_source" else None),
|
|
2176
|
+
"no_undo": group != "plsql_source",
|
|
1972
2177
|
"restore_is_drop": created,
|
|
1973
2178
|
"restore_blocked_by_policy": (
|
|
1974
2179
|
created and undo_group_action(ns) == "deny"),
|
|
1975
2180
|
"exit": 0 if ok else 3}))
|
|
1976
2181
|
else:
|
|
1977
2182
|
en = getattr(ns, "color", False)
|
|
1978
|
-
if ok:
|
|
2183
|
+
if ok and group == "data_dml":
|
|
2184
|
+
print(paint(f"\n Applied — {affected} rows changed and "
|
|
2185
|
+
"committed.", "green", en))
|
|
2186
|
+
elif ok:
|
|
1979
2187
|
print(paint(f"\n Applied {name} ({otype}).", "green", en))
|
|
1980
2188
|
print(paint(" Compiled clean. No new INVALID objects.", "green", en))
|
|
1981
2189
|
else:
|
|
@@ -1992,21 +2200,28 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
1992
2200
|
print(f" {len(broke)} objects were VALID before and are INVALID now:")
|
|
1993
2201
|
for n2, t2 in broke:
|
|
1994
2202
|
print(paint(f" {n2} ({t2})", "red", en))
|
|
1995
|
-
if
|
|
1996
|
-
|
|
1997
|
-
|
|
2203
|
+
if group != "plsql_source":
|
|
2204
|
+
# no snapshot was taken, so there is nothing to restore — say that
|
|
2205
|
+
# instead of printing a restore command that would be refused
|
|
2206
|
+
print(paint("\n There is no undo: the journal kept the statement "
|
|
2207
|
+
"that ran, not a way back.", "yellow", en))
|
|
1998
2208
|
else:
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2209
|
+
if created:
|
|
2210
|
+
print("\n To undo — note: undo means DROPPING it "
|
|
2211
|
+
"(it did not exist before):")
|
|
2212
|
+
else:
|
|
2213
|
+
print("\n To undo:")
|
|
2214
|
+
print(" " + paint(f"{invocation()} journal restore {entry}",
|
|
2215
|
+
"cyan", en))
|
|
2216
|
+
if created and undo_group_action(ns) == "deny":
|
|
2217
|
+
# the honest half: that restore is a DROP, DROP is structural,
|
|
2218
|
+
# and structural is deny — so the line above would be refused
|
|
2219
|
+
print(paint(" ...but that restore is a DROP, and structural "
|
|
2220
|
+
"is set to deny, so it will be refused.",
|
|
2221
|
+
"yellow", en))
|
|
2222
|
+
print(paint(" The developer decides: "
|
|
2223
|
+
f"{invocation()} policy set structural confirm",
|
|
2224
|
+
"yellow", en))
|
|
2010
2225
|
return 0 if ok else 3
|
|
2011
2226
|
|
|
2012
2227
|
|
|
@@ -2033,6 +2248,32 @@ def find_preview_by_token(root, token):
|
|
|
2033
2248
|
return None, None
|
|
2034
2249
|
|
|
2035
2250
|
|
|
2251
|
+
def token_mismatch_message(ns, row_set):
|
|
2252
|
+
"""Why the token no longer matches. For DML the interesting answer is
|
|
2253
|
+
almost always the rows, and the preview's own journal entry still holds
|
|
2254
|
+
what they were — so say which, instead of a generic 'something moved'."""
|
|
2255
|
+
base = ("The confirmation token does not match: the file or the "
|
|
2256
|
+
"database object changed since that preview. Preview again:\n"
|
|
2257
|
+
f" {invocation()} apply {ns.file}")
|
|
2258
|
+
if not row_set:
|
|
2259
|
+
return base
|
|
2260
|
+
_, meta = find_preview_by_token(ns.project_root,
|
|
2261
|
+
str(ns.confirm).strip().lower())
|
|
2262
|
+
then = (meta or {}).get("row_set") or {}
|
|
2263
|
+
if not then or fingerprint_text(then) == fingerprint_text(row_set):
|
|
2264
|
+
return base # the file moved, not the rows
|
|
2265
|
+
if then["count"] != row_set["count"]:
|
|
2266
|
+
moved = (f"{then['count']} rows when you approved it, "
|
|
2267
|
+
f"{row_set['count']} now")
|
|
2268
|
+
else:
|
|
2269
|
+
moved = (f"still {row_set['count']} rows, but not the same ones — the "
|
|
2270
|
+
"row fingerprint changed")
|
|
2271
|
+
return ("Refused: the row set moved — " + moved + ".\n"
|
|
2272
|
+
"Nothing was written. The approval covered the rows as they were.\n"
|
|
2273
|
+
"Preview again to see them as they are now:\n"
|
|
2274
|
+
f" {invocation()} apply {ns.file}")
|
|
2275
|
+
|
|
2276
|
+
|
|
2036
2277
|
def cmd_approve(conn, schema, ns):
|
|
2037
2278
|
"""The developer's half of the gate: one human act, at a real console,
|
|
2038
2279
|
that lets the agent's next `apply --confirm` through. Touches no
|
|
@@ -2052,12 +2293,15 @@ def cmd_approve(conn, schema, ns):
|
|
|
2052
2293
|
f" {invocation()} apply <file>")
|
|
2053
2294
|
conn_name = meta.get("connection") or ""
|
|
2054
2295
|
prune_expired_grants(ns.project_root)
|
|
2055
|
-
|
|
2296
|
+
row_set = meta.get("row_set")
|
|
2297
|
+
rec = mint_grant(ns.project_root, token, conn_name,
|
|
2298
|
+
revalidate=fingerprint_text(row_set))
|
|
2056
2299
|
obj, otype = meta.get("object", "?"), meta.get("type", "?")
|
|
2057
2300
|
group = meta.get("group", "")
|
|
2058
2301
|
if ns.json:
|
|
2059
2302
|
print(json.dumps({"ok": True, "token": token, "conn": conn_name,
|
|
2060
2303
|
"object": obj, "type": otype, "group": group,
|
|
2304
|
+
"rows": (row_set or {}).get("count"),
|
|
2061
2305
|
"entry": entry, "minted_at": rec["minted_at"],
|
|
2062
2306
|
"expires_at": rec["expires_at"],
|
|
2063
2307
|
"ttl_minutes": GRANT_TTL_MINUTES}))
|
|
@@ -2071,6 +2315,12 @@ def cmd_approve(conn, schema, ns):
|
|
|
2071
2315
|
print(f" Approving a {group} statement on {meta.get('schema', '?')}:")
|
|
2072
2316
|
for ln in stmt.splitlines():
|
|
2073
2317
|
print(f" {ln}")
|
|
2318
|
+
if row_set:
|
|
2319
|
+
# the rows themselves, exactly as the preview showed them: what is
|
|
2320
|
+
# approved has to be what was seen
|
|
2321
|
+
print()
|
|
2322
|
+
for ln in render_row_set(row_set, en, indent=" "):
|
|
2323
|
+
print(ln)
|
|
2074
2324
|
print(paint("\n There is no snapshot for this group — after commit "
|
|
2075
2325
|
"it cannot be undone from the journal.", "yellow", en))
|
|
2076
2326
|
else:
|
|
@@ -2090,6 +2340,18 @@ def run_restore(conn, schema, ns):
|
|
|
2090
2340
|
"""Restore is itself a write: feed the saved statement back through the
|
|
2091
2341
|
same six steps. There is no second write path and no silent restore."""
|
|
2092
2342
|
e = read_journal_entry(ns.project_root, ns.id)
|
|
2343
|
+
group = e["meta"].get("group", "plsql_source")
|
|
2344
|
+
if group != "plsql_source":
|
|
2345
|
+
# render_restore() writes a DROP for every group that has no snapshot,
|
|
2346
|
+
# which classifies as structural and dies with a message about the
|
|
2347
|
+
# wrong thing. Say what is actually true, once, for all of them.
|
|
2348
|
+
tail = ("Flashback Query within undo retention is the only route left, "
|
|
2349
|
+
"and it is a DBA's job."
|
|
2350
|
+
if group == "data_dml" else
|
|
2351
|
+
"Reversing it means writing the opposite statement by hand.")
|
|
2352
|
+
sys.exit(f"Refused: entry {ns.id} is a {group} statement, and the "
|
|
2353
|
+
"journal holds no undo for it — only the statement that "
|
|
2354
|
+
f"ran.\n{tail}")
|
|
2093
2355
|
print(f"Restoring from {ns.id} — this is itself a write and goes through "
|
|
2094
2356
|
"the full six steps.", file=sys.stderr)
|
|
2095
2357
|
return run_apply(conn, schema, ns, e["restore"], origin={"restored_from": ns.id})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythia-plsql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/thaildhe172591/pythia
|
|
@@ -211,7 +211,7 @@ Per-group write policy, `.pythia/policy.json` (defaults shown):
|
|
|
211
211
|
| Group | Default | Is rollback real? |
|
|
212
212
|
|---|---|---|
|
|
213
213
|
| `plsql_source` | `confirm` | **Yes — completely.** Source is recoverable from `ALL_SOURCE`. |
|
|
214
|
-
| `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. |
|
|
214
|
+
| `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. Revalidation checks the row set *before* the write; it is not an undo. |
|
|
215
215
|
| `structural` | `deny` | **Almost never.** `DROP COLUMN` is permanent; a dropped table may be in the Recycle Bin. |
|
|
216
216
|
| `grants` | `deny` | Yes, but by hand. |
|
|
217
217
|
| `session` | `allow` | Not needed. |
|
|
@@ -9,9 +9,9 @@ description: Use when a PL/SQL change is ready to reach the database - applying
|
|
|
9
9
|
|
|
10
10
|
**Phase:** Ask → Do — the preview is relayed and approved before the one write door opens
|
|
11
11
|
|
|
12
|
-
DDL in Oracle commits itself
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
DDL in Oracle commits itself, so the snapshot pythia takes before writing is
|
|
13
|
+
the only undo there is — and for DML not even that. This skill exists so that
|
|
14
|
+
net is always used, and used honestly.
|
|
15
15
|
|
|
16
16
|
## The Iron Law
|
|
17
17
|
|
|
@@ -41,9 +41,8 @@ message.
|
|
|
41
41
|
Sometimes the developer runs the file themselves — a DBA executes it, a
|
|
42
42
|
release process owns it, or policy denies the group. The preview still ran, so
|
|
43
43
|
a rollback file for the live version exists at
|
|
44
|
-
`.pythia/journal/<entry>/restore.sql
|
|
45
|
-
|
|
46
|
-
history <OBJECT>` lists every captured version).
|
|
44
|
+
`.pythia/journal/<entry>/restore.sql` (`pythia history <OBJECT>` lists every
|
|
45
|
+
captured version). Never let a change leave your hands without naming it.
|
|
47
46
|
|
|
48
47
|
## The Workflow
|
|
49
48
|
|
|
@@ -92,10 +91,9 @@ means DROP — policy refuses it under `structural: deny`, and that is correct.
|
|
|
92
91
|
|
|
93
92
|
## Batch mode
|
|
94
93
|
|
|
95
|
-
`--yes` skips the pause, not the preview —
|
|
96
|
-
their terminal it *is* the approval
|
|
97
|
-
|
|
98
|
-
confirm the scope once and default to this-batch-only. It never covers restores.
|
|
94
|
+
`--yes` skips the pause, not the preview — it is the developer's flag, and at
|
|
95
|
+
their terminal it *is* the approval. A frustrated "stop asking" grants it for
|
|
96
|
+
the task at hand, not from now on; it never covers restores.
|
|
99
97
|
|
|
100
98
|
- **Stop at the first exit 3.** Never keep applying onto a broken state.
|
|
101
99
|
- Afterwards report: one line per success, full detail (errors, newly INVALID,
|
|
@@ -114,7 +112,7 @@ information for the developer, not an obstacle to route around.
|
|
|
114
112
|
| Group | Is rollback real? |
|
|
115
113
|
|---|---|
|
|
116
114
|
| `plsql_source` | **Yes — completely.** The source is recoverable from `ALL_SOURCE`. |
|
|
117
|
-
| `data_dml` | **No.** After commit only Flashback Query remains, and only within undo retention. |
|
|
115
|
+
| `data_dml` | **No.** After commit only Flashback Query remains, and only within undo retention. Revalidation checks the row set *before* the write; it is not an undo. |
|
|
118
116
|
| `structural` | **Almost never.** `DROP COLUMN` is permanent; a dropped table may be in the Recycle Bin. |
|
|
119
117
|
| `grants` | Yes, but by hand. |
|
|
120
118
|
| `session` | Not needed. |
|
|
@@ -122,6 +120,10 @@ information for the developer, not an obstacle to route around.
|
|
|
122
120
|
Never promise "we can always roll back" — true only for the first row, and
|
|
123
121
|
saying it generally misleads the developer when the stakes are highest.
|
|
124
122
|
|
|
123
|
+
`data_dml` is revalidated on its **rows**: preview and `approve` show the
|
|
124
|
+
affected count and up to ten rows, and `--confirm` refuses if that set moved.
|
|
125
|
+
`MERGE`, and anything unmeasurable, is refused rather than guessed at.
|
|
126
|
+
|
|
125
127
|
## Red Flags — STOP if you catch yourself thinking
|
|
126
128
|
|
|
127
129
|
| Thought | Reality |
|
|
@@ -133,9 +135,7 @@ saying it generally misleads the developer when the stakes are highest.
|
|
|
133
135
|
| "apply refused it; run-sql will take it" | The refusal is the product working. Relay it. |
|
|
134
136
|
| "I'll restore quietly to clean up my mistake" | Restores are writes. Same gate, same visibility. |
|
|
135
137
|
| "`$?` said 0 after I piped to tail" | That was tail's 0. Read pythia's own words, or its unpiped code. |
|
|
136
|
-
| "The dev said 'stop asking' once" | That covered that task, not forever. Re-confirm scope on the next one. |
|
|
137
138
|
| "I'll run approve myself to unblock this" | The developer's console act. It refuses you, and routing around it is what this gate exists to stop. |
|
|
138
|
-
| "No approval on file — I'll retry until it works" | Retrying does not create approval. Relay the line, wait for a human. |
|
|
139
139
|
|
|
140
140
|
## When NOT to use this skill
|
|
141
141
|
|
|
@@ -70,6 +70,43 @@ def test_prepare_statement_terminators():
|
|
|
70
70
|
"truncate table a; truncate table b;", "structural"), "one statement")
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_mask_literals_keeps_length_and_blanks_text():
|
|
74
|
+
m = pythia.mask_literals
|
|
75
|
+
src = "update t set n = 'x where y' where id = 1"
|
|
76
|
+
assert len(m(src)) == len(src)
|
|
77
|
+
assert "where y" not in m(src)
|
|
78
|
+
assert m("delete from t -- where later" + chr(10)
|
|
79
|
+
+ "where id = 1").count("where") == 1
|
|
80
|
+
assert m("delete from t /* where */ where id = 1").count("where") == 1
|
|
81
|
+
assert "it" not in m("update t set n = 'it''s' where id = 1")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_dml_probe_reads_target_and_predicate():
|
|
85
|
+
assert pythia.dml_probe("delete from t_order where status = 'DRAFT'") == (
|
|
86
|
+
"delete", "t_order", "status = 'DRAFT'")
|
|
87
|
+
assert pythia.dml_probe("delete app.t_order t where t.id = 7") == (
|
|
88
|
+
"delete", "app.t_order t", "t.id = 7")
|
|
89
|
+
assert pythia.dml_probe("update t_order t set status = 'X' "
|
|
90
|
+
"where t.id = 7") == (
|
|
91
|
+
"update", "t_order t", "t.id = 7")
|
|
92
|
+
assert pythia.dml_probe("delete from t_order") == ("delete", "t_order", None)
|
|
93
|
+
assert pythia.dml_probe("insert into t values (1)") is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_dml_probe_refuses_what_it_cannot_measure():
|
|
97
|
+
expect_exit(lambda: pythia.dml_probe(
|
|
98
|
+
"merge into t using d on (1=1) when matched then update set x=1"),
|
|
99
|
+
"merge", "join")
|
|
100
|
+
expect_exit(lambda: pythia.dml_probe(
|
|
101
|
+
"delete from t where id in (select id from u)"), "subquery")
|
|
102
|
+
expect_exit(lambda: pythia.dml_probe(
|
|
103
|
+
"update t set a = 1 where id in (1) and x = 2 where y = 3"),
|
|
104
|
+
"more than one where")
|
|
105
|
+
expect_exit(lambda: pythia.dml_probe(
|
|
106
|
+
"update t set n = q'{x}' where id = 1"), "quoted")
|
|
107
|
+
expect_exit(lambda: pythia.dml_probe("update t where id = 1"), "set clause")
|
|
108
|
+
|
|
109
|
+
|
|
73
110
|
def test_apply_token_is_content_bound():
|
|
74
111
|
t1 = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", "create...", "old src")
|
|
75
112
|
assert len(t1) == 6 and all(c in "0123456789abcdef" for c in t1)
|
|
@@ -135,9 +172,10 @@ def test_newly_invalid_and_diff():
|
|
|
135
172
|
|
|
136
173
|
|
|
137
174
|
class FakeCursor:
|
|
138
|
-
def __init__(self, script, executed):
|
|
175
|
+
def __init__(self, script, executed, rowcount=0):
|
|
139
176
|
self.script, self.executed = script, executed
|
|
140
177
|
self.description, self._rows = None, []
|
|
178
|
+
self.rowcount = rowcount
|
|
141
179
|
|
|
142
180
|
def execute(self, sql, binds=None):
|
|
143
181
|
self.executed.append((sql, dict(binds or {})))
|
|
@@ -166,11 +204,19 @@ class FakeCursor:
|
|
|
166
204
|
|
|
167
205
|
|
|
168
206
|
class FakeConn:
|
|
169
|
-
def __init__(self, script):
|
|
207
|
+
def __init__(self, script, rowcount=0):
|
|
170
208
|
self.script, self.executed = script, []
|
|
209
|
+
self.rowcount = rowcount
|
|
210
|
+
self.commits, self.rollbacks = 0, 0
|
|
171
211
|
|
|
172
212
|
def cursor(self):
|
|
173
|
-
return FakeCursor(self.script, self.executed)
|
|
213
|
+
return FakeCursor(self.script, self.executed, self.rowcount)
|
|
214
|
+
|
|
215
|
+
def commit(self):
|
|
216
|
+
self.commits += 1
|
|
217
|
+
|
|
218
|
+
def rollback(self):
|
|
219
|
+
self.rollbacks += 1
|
|
174
220
|
|
|
175
221
|
|
|
176
222
|
OLD_SRC = "PACKAGE BODY pkg_order AS\n old line;\nEND;\n"
|
|
@@ -214,6 +260,166 @@ def wrote_ddl(conn):
|
|
|
214
260
|
return [s for s, _ in conn.executed if s.lstrip().lower().startswith("create")]
|
|
215
261
|
|
|
216
262
|
|
|
263
|
+
DML_FILE = "delete from t_order where status = 'DRAFT'\n"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def dml_script(count=2, hashes=(11, 1, 9), rows=((7, "DRAFT"), (8, "DRAFT"))):
|
|
267
|
+
s = base_script()
|
|
268
|
+
s["ora_hash"] = (("N", "S", "LO", "HI"), [(count,) + tuple(hashes)])
|
|
269
|
+
s["select * from"] = (("ID", "STATUS"), list(rows))
|
|
270
|
+
return s
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def dml_ns(root, **kw):
|
|
274
|
+
ns = apply_ns(root, **kw)
|
|
275
|
+
ns.file = "d.sql"
|
|
276
|
+
return ns
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def allow_dml(root):
|
|
280
|
+
d = pathlib.Path(root) / ".pythia"
|
|
281
|
+
d.mkdir(exist_ok=True)
|
|
282
|
+
(d / "policy.json").write_text(json.dumps({"data_dml": "confirm"}),
|
|
283
|
+
encoding="utf-8")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def wrote_dml(conn):
|
|
287
|
+
return [s for s, _ in conn.executed
|
|
288
|
+
if s.lstrip().lower().startswith(("delete", "update", "insert"))]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_dml_preview_measures_the_row_set_and_writes_nothing():
|
|
292
|
+
with tempfile.TemporaryDirectory() as td:
|
|
293
|
+
allow_dml(td)
|
|
294
|
+
conn = FakeConn(dml_script())
|
|
295
|
+
code = pythia.run_apply(conn, "APP", dml_ns(td), DML_FILE)
|
|
296
|
+
assert code == 0
|
|
297
|
+
assert wrote_dml(conn) == [] and conn.commits == 0
|
|
298
|
+
entry = pythia.list_journal_entries(td)[0]
|
|
299
|
+
rs = pythia.read_journal_entry(td, entry)["meta"]["row_set"]
|
|
300
|
+
assert rs["count"] == 2 and rs["target"] == "t_order"
|
|
301
|
+
assert rs["predicate"] == "status = 'DRAFT'"
|
|
302
|
+
assert rs["sample"] == [["7", "DRAFT"], ["8", "DRAFT"]]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_the_row_set_moves_the_token():
|
|
306
|
+
with tempfile.TemporaryDirectory() as td:
|
|
307
|
+
allow_dml(td)
|
|
308
|
+
pythia.run_apply(FakeConn(dml_script(count=2)), "APP",
|
|
309
|
+
dml_ns(td), DML_FILE)
|
|
310
|
+
first = pythia.read_journal_entry(
|
|
311
|
+
td, pythia.list_journal_entries(td)[0])["meta"]["token"]
|
|
312
|
+
pythia.run_apply(FakeConn(dml_script(count=3)), "APP",
|
|
313
|
+
dml_ns(td), DML_FILE)
|
|
314
|
+
second = pythia.read_journal_entry(
|
|
315
|
+
td, pythia.list_journal_entries(td)[0])["meta"]["token"]
|
|
316
|
+
assert first != second, "the fingerprint must reach the token"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def approved(td, conn):
|
|
320
|
+
"""Preview, then approve — the two-step gate, in test form. Returns the
|
|
321
|
+
token the agent would pass to --confirm."""
|
|
322
|
+
pythia.run_apply(conn, "APP", dml_ns(td), DML_FILE)
|
|
323
|
+
entry = pythia.list_journal_entries(td)[0]
|
|
324
|
+
token = pythia.read_journal_entry(td, entry)["meta"]["token"]
|
|
325
|
+
pythia.mint_grant(td, token, "DEV")
|
|
326
|
+
return token
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_dml_commits_once_when_the_rowcount_matches():
|
|
330
|
+
with tempfile.TemporaryDirectory() as td:
|
|
331
|
+
allow_dml(td)
|
|
332
|
+
token = approved(td, FakeConn(dml_script(count=2)))
|
|
333
|
+
conn = FakeConn(dml_script(count=2), rowcount=2)
|
|
334
|
+
code = pythia.run_apply(conn, "APP", dml_ns(td, confirm=token), DML_FILE)
|
|
335
|
+
assert code == 0
|
|
336
|
+
assert conn.commits == 1 and conn.rollbacks == 0
|
|
337
|
+
assert wrote_dml(conn)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_dml_rolls_back_when_the_rowcount_diverges():
|
|
341
|
+
with tempfile.TemporaryDirectory() as td:
|
|
342
|
+
allow_dml(td)
|
|
343
|
+
token = approved(td, FakeConn(dml_script(count=2)))
|
|
344
|
+
conn = FakeConn(dml_script(count=2), rowcount=5)
|
|
345
|
+
expect_exit(lambda: pythia.run_apply(
|
|
346
|
+
conn, "APP", dml_ns(td, confirm=token), DML_FILE),
|
|
347
|
+
"rolled back", "5", "2")
|
|
348
|
+
assert conn.rollbacks == 1 and conn.commits == 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_a_moved_row_set_refuses_the_confirm():
|
|
352
|
+
with tempfile.TemporaryDirectory() as td:
|
|
353
|
+
allow_dml(td)
|
|
354
|
+
token = approved(td, FakeConn(dml_script(count=2)))
|
|
355
|
+
conn = FakeConn(dml_script(count=3), rowcount=3)
|
|
356
|
+
expect_exit(lambda: pythia.run_apply(
|
|
357
|
+
conn, "APP", dml_ns(td, confirm=token), DML_FILE),
|
|
358
|
+
"row set moved", "2 rows", "3")
|
|
359
|
+
assert conn.commits == 0 and wrote_dml(conn) == []
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_dml_preview_promises_no_object_and_no_rollback_file():
|
|
363
|
+
"""A DML statement is not a "new object", and the restore.sql generated
|
|
364
|
+
for it is a DROP nothing would accept — the preview must offer neither."""
|
|
365
|
+
import contextlib
|
|
366
|
+
import io
|
|
367
|
+
with tempfile.TemporaryDirectory() as td:
|
|
368
|
+
allow_dml(td)
|
|
369
|
+
out = io.StringIO()
|
|
370
|
+
with contextlib.redirect_stdout(out):
|
|
371
|
+
pythia.run_apply(FakeConn(dml_script()), "APP", dml_ns(td), DML_FILE)
|
|
372
|
+
text = out.getvalue()
|
|
373
|
+
assert "data_dml statement on APP" in text
|
|
374
|
+
assert "new object" not in text and "restore.sql" not in text
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_restore_refuses_an_entry_that_has_no_undo():
|
|
378
|
+
with tempfile.TemporaryDirectory() as td:
|
|
379
|
+
entry = pythia.write_journal_entry(
|
|
380
|
+
td, "DATA_DML", "STATEMENT", "",
|
|
381
|
+
"delete from t_order where id = 7",
|
|
382
|
+
{"connection": "DEV", "group": "data_dml", "applied": True})
|
|
383
|
+
ns = dml_ns(td, id=entry)
|
|
384
|
+
expect_exit(lambda: pythia.run_restore(FakeConn(base_script()),
|
|
385
|
+
"APP", ns),
|
|
386
|
+
"no undo", "flashback")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_deny_message_explains_what_revalidation_buys():
|
|
390
|
+
with tempfile.TemporaryDirectory() as td:
|
|
391
|
+
expect_exit(lambda: pythia.run_apply(FakeConn(dml_script()), "APP",
|
|
392
|
+
dml_ns(td), DML_FILE),
|
|
393
|
+
"deny", "revalidation", "policy set data_dml confirm")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_approve_records_the_row_set_it_showed():
|
|
397
|
+
with tempfile.TemporaryDirectory() as td:
|
|
398
|
+
allow_dml(td)
|
|
399
|
+
pythia.run_apply(FakeConn(dml_script(count=2)), "APP",
|
|
400
|
+
dml_ns(td), DML_FILE)
|
|
401
|
+
meta = pythia.read_journal_entry(
|
|
402
|
+
td, pythia.list_journal_entries(td)[0])["meta"]
|
|
403
|
+
rec = pythia.mint_grant(
|
|
404
|
+
td, meta["token"], "DEV",
|
|
405
|
+
revalidate=pythia.fingerprint_text(meta["row_set"]))
|
|
406
|
+
assert rec["revalidate"].startswith("rows=2 hash=")
|
|
407
|
+
assert pythia.read_grant(td, meta["token"])["revalidate"] \
|
|
408
|
+
== rec["revalidate"]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_insert_needs_no_row_set():
|
|
412
|
+
with tempfile.TemporaryDirectory() as td:
|
|
413
|
+
allow_dml(td)
|
|
414
|
+
conn = FakeConn(base_script())
|
|
415
|
+
code = pythia.run_apply(conn, "APP", dml_ns(td),
|
|
416
|
+
"insert into t_order (id) values (1)\n")
|
|
417
|
+
assert code == 0
|
|
418
|
+
assert not [s for s, _ in conn.executed if "ora_hash" in s.lower()]
|
|
419
|
+
entry = pythia.list_journal_entries(td)[0]
|
|
420
|
+
assert pythia.read_journal_entry(td, entry)["meta"]["row_set"] is None
|
|
421
|
+
|
|
422
|
+
|
|
217
423
|
def test_apply_preview_writes_nothing_and_gives_token():
|
|
218
424
|
with tempfile.TemporaryDirectory() as td:
|
|
219
425
|
conn = FakeConn(base_script())
|
|
@@ -98,7 +98,8 @@ def test_apply_skill_keeps_the_honest_rollback_table():
|
|
|
98
98
|
return
|
|
99
99
|
text = path.read_text(encoding="utf-8")
|
|
100
100
|
for needle in ("Yes — completely", "Flashback Query", "Recycle Bin",
|
|
101
|
-
"Almost never", "exit 3", "run-sql"
|
|
101
|
+
"Almost never", "exit 3", "run-sql",
|
|
102
|
+
"revalidat"): # 0.9.0: the row-set gate
|
|
102
103
|
assert needle in text, f"pythia-apply: missing {needle!r}"
|
|
103
104
|
|
|
104
105
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-explore/reference/data-dictionary.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|