|
|
@@ -0,0 +1,189 @@
|
|
|
+package pgserver
|
|
|
+
|
|
|
+// Compatibility suite for the pChat-style workload: DDL, upsert idempotency,
|
|
|
+// constraints, pagination, parameters, and error codes. It runs only when
|
|
|
+// PIZZASQL_COMPAT=1 so the regular unit suite stays fast; `make compat` enables
|
|
|
+// it and writes a JSON report.
|
|
|
+//
|
|
|
+// The suite distinguishes:
|
|
|
+// - pass: the build behaves as documented
|
|
|
+// - missing: a documented capability is absent from this build (for example a
|
|
|
+// rejection introduced by a newer release)
|
|
|
+// - fail: behavior contradicts the documented contract
|
|
|
+//
|
|
|
+// PIZZASQL_COMPAT_STRICT=1 turns "missing" into a failure, which is what a
|
|
|
+// release gate should use. The compatibility document is pkg/capabilities.
|
|
|
+
|
|
|
+import (
|
|
|
+ "encoding/json"
|
|
|
+ "os"
|
|
|
+ "strconv"
|
|
|
+ "testing"
|
|
|
+)
|
|
|
+
|
|
|
+func rowValue(rows [][]string, index int) string {
|
|
|
+ if len(rows) <= index || len(rows[index]) == 0 {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+ return rows[index][0]
|
|
|
+}
|
|
|
+
|
|
|
+type compatResult struct {
|
|
|
+ Name string `json:"name"`
|
|
|
+ Status string `json:"status"` // pass, missing, fail
|
|
|
+ Detail string `json:"detail,omitempty"`
|
|
|
+}
|
|
|
+
|
|
|
+type compatReport struct {
|
|
|
+ Engine string `json:"engine"`
|
|
|
+ Results []compatResult `json:"results"`
|
|
|
+}
|
|
|
+
|
|
|
+func TestCompatibilitySuite(t *testing.T) {
|
|
|
+ if os.Getenv("PIZZASQL_COMPAT") != "1" {
|
|
|
+ t.Skip("set PIZZASQL_COMPAT=1 (or run make compat) to run the compatibility suite")
|
|
|
+ }
|
|
|
+ strict := os.Getenv("PIZZASQL_COMPAT_STRICT") == "1"
|
|
|
+
|
|
|
+ c, client := newDataConnection(t)
|
|
|
+ report := compatReport{Engine: "pizzasql"}
|
|
|
+
|
|
|
+ record := func(name, status, detail string) {
|
|
|
+ report.Results = append(report.Results, compatResult{Name: name, Status: status, Detail: detail})
|
|
|
+ }
|
|
|
+
|
|
|
+ // exec runs a statement and returns the SQLSTATE code ("" on success) and
|
|
|
+ // decoded rows.
|
|
|
+ exec := func(sql string) (string, [][]string) {
|
|
|
+ msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(sql), 0)})
|
|
|
+ if len(msgs) == 0 {
|
|
|
+ return "", nil
|
|
|
+ }
|
|
|
+ if msgs[0].Type == MsgErrorResponse {
|
|
|
+ return errorCode(msgs[0]), nil
|
|
|
+ }
|
|
|
+ var rows [][]string
|
|
|
+ for _, msg := range msgs[1:] {
|
|
|
+ if msg.Type != MsgDataRow {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ rows = append(rows, dataRowValues(t, msg))
|
|
|
+ }
|
|
|
+ return "", rows
|
|
|
+ }
|
|
|
+ expectOK := func(name, sql string) {
|
|
|
+ if code, _ := exec(sql); code != "" {
|
|
|
+ record(name, "fail", "unexpected SQLSTATE "+code)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ record(name, "pass", "")
|
|
|
+ }
|
|
|
+ expectCode := func(name, sql, want string) {
|
|
|
+ code, _ := exec(sql)
|
|
|
+ switch code {
|
|
|
+ case want:
|
|
|
+ record(name, "pass", "")
|
|
|
+ case "":
|
|
|
+ record(name, "missing", "statement succeeded; this build does not enforce "+want)
|
|
|
+ default:
|
|
|
+ record(name, "missing", "got SQLSTATE "+code+", want "+want)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ expectRows := func(name, sql string, want ...string) {
|
|
|
+ code, rows := exec(sql)
|
|
|
+ if code != "" {
|
|
|
+ record(name, "fail", "unexpected SQLSTATE "+code)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if len(rows) != 1 || len(rows[0]) != len(want) {
|
|
|
+ record(name, "fail", "unexpected rows")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ for i := range want {
|
|
|
+ if rows[0][i] != want[i] {
|
|
|
+ record(name, "fail", "row "+rows[0][i]+" != "+want[i])
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ record(name, "pass", "")
|
|
|
+ }
|
|
|
+
|
|
|
+ expectOK("ddl.create_table", `CREATE TABLE accounts (
|
|
|
+ id INTEGER PRIMARY KEY,
|
|
|
+ email TEXT NOT NULL UNIQUE,
|
|
|
+ plan TEXT NOT NULL DEFAULT 'free',
|
|
|
+ seats INTEGER NOT NULL DEFAULT 1
|
|
|
+ )`)
|
|
|
+ expectOK("dml.insert", `INSERT INTO accounts (id, email, plan, seats) VALUES (1, 'a@x', 'pro', 3)`)
|
|
|
+ expectRows("dml.select_constant_default", `SELECT plan, seats FROM accounts WHERE id = 1`, "pro", "3")
|
|
|
+
|
|
|
+ expectOK("upsert.first", `INSERT INTO accounts (id, email, plan) VALUES (2, 'b@x', 'free') ON CONFLICT(id) DO UPDATE SET plan = excluded.plan`)
|
|
|
+ expectOK("upsert.idempotent", `INSERT INTO accounts (id, email, plan) VALUES (2, 'b@x', 'pro') ON CONFLICT(id) DO UPDATE SET plan = excluded.plan`)
|
|
|
+ expectRows("upsert.result", `SELECT plan FROM accounts WHERE id = 2`, "pro")
|
|
|
+
|
|
|
+ expectCode("constraints.unique_23505", `INSERT INTO accounts (id, email) VALUES (3, 'a@x')`, "23505")
|
|
|
+ expectCode("ddl.composite_pk_0A000", `CREATE TABLE bad_pk (a INTEGER, b INTEGER, PRIMARY KEY (a, b))`, "0A000")
|
|
|
+ expectCode("ddl.nonconstant_default_0A000", `CREATE TABLE bad_default (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`, "0A000")
|
|
|
+ expectCode("locking.for_update_0A000", `SELECT * FROM accounts FOR UPDATE`, "0A000")
|
|
|
+ expectCode("arrays.any_0A000", `SELECT * FROM accounts WHERE id = ANY('{1,2}')`, "0A000")
|
|
|
+
|
|
|
+ // Documented current limitation: CHECK and FOREIGN KEY are parsed but not
|
|
|
+ // enforced. A release that starts rejecting them will report "missing"
|
|
|
+ // here and the capability document must be updated.
|
|
|
+ expectOK("limitations.check_not_enforced", `CREATE TABLE checked (n INTEGER CHECK (n > 0))`)
|
|
|
+ expectOK("limitations.check_insert_violation", `INSERT INTO checked (n) VALUES (-1)`)
|
|
|
+ expectOK("limitations.foreign_key_not_enforced", `CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES accounts(id))`)
|
|
|
+ expectOK("limitations.orphan_insert", `INSERT INTO child (id, parent_id) VALUES (1, 999)`)
|
|
|
+
|
|
|
+ expectCode("errors.syntax_42601", `SELEC 1`, "42601")
|
|
|
+
|
|
|
+ // Explicit transactions buffer writes and validate unique indexes at
|
|
|
+ // COMMIT, so the duplicate insert itself succeeds and the commit fails.
|
|
|
+ expectOK("tx.begin", `BEGIN`)
|
|
|
+ expectOK("tx.buffer_duplicate", `INSERT INTO accounts (id, email) VALUES (3, 'a@x')`)
|
|
|
+ expectCode("tx.commit_unique_23505", `COMMIT`, "23505")
|
|
|
+ // A failed COMMIT has already rolled back; the session must be usable
|
|
|
+ // again instead of being stuck in the failed-transaction state.
|
|
|
+ expectRows("tx.after_failed_commit_idle", `SELECT 1`, "1")
|
|
|
+ expectOK("tx.begin_after_failed_commit", `BEGIN`)
|
|
|
+ expectOK("tx.rollback", `ROLLBACK`)
|
|
|
+
|
|
|
+ expectOK("pagination.seed", `INSERT INTO accounts (id, email) VALUES (10, 'p10@x'), (11, 'p11@x'), (12, 'p12@x')`)
|
|
|
+ code, rows := exec(`SELECT _rowid_ FROM accounts WHERE _rowid_ > 1 ORDER BY _rowid_ LIMIT 2`)
|
|
|
+ first, firstErr := strconv.ParseInt(rowValue(rows, 0), 10, 64)
|
|
|
+ second, secondErr := strconv.ParseInt(rowValue(rows, 1), 10, 64)
|
|
|
+ switch {
|
|
|
+ case code != "":
|
|
|
+ record("pagination.rowid", "fail", "unexpected SQLSTATE "+code)
|
|
|
+ case len(rows) == 2 && firstErr == nil && secondErr == nil && first < second:
|
|
|
+ record("pagination.rowid", "pass", "")
|
|
|
+ default:
|
|
|
+ record("pagination.rowid", "fail", "cursor did not advance")
|
|
|
+ }
|
|
|
+
|
|
|
+ failures, missing := 0, 0
|
|
|
+ for _, result := range report.Results {
|
|
|
+ switch result.Status {
|
|
|
+ case "fail":
|
|
|
+ failures++
|
|
|
+ t.Errorf("%s: %s", result.Name, result.Detail)
|
|
|
+ case "missing":
|
|
|
+ missing++
|
|
|
+ t.Logf("%s: %s", result.Name, result.Detail)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if path := os.Getenv("PIZZASQL_COMPAT_REPORT"); path != "" {
|
|
|
+ body, err := json.MarshalIndent(report, "", " ")
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("marshal report: %v", err)
|
|
|
+ }
|
|
|
+ if err := os.WriteFile(path, body, 0o644); err != nil {
|
|
|
+ t.Fatalf("write report: %v", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if failures > 0 || (strict && missing > 0) {
|
|
|
+ t.Fatalf("compatibility failures=%d missing=%d (strict=%v)", failures, missing, strict)
|
|
|
+ }
|
|
|
+}
|