Skip to content
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,12 @@ sodacli datasource onboard <datasource-id> --monitoring --profiling --contracts
### 3. Verify a contract

```bash
# Run checks via Soda Cloud Runner
# Run checks via Soda Cloud Runner (local file)
sodacli contract verify orders.yml

# Run checks via Soda Cloud Runner using dataset DQN — no local file needed
sodacli contract verify datasource/db/schema/table

# Or run locally via soda-core (no cloud needed)
sodacli contract verify orders.yml --local --datasource datasource.yml

Expand Down Expand Up @@ -176,7 +179,8 @@ sodacli contract push my_table.yml # uploa
sodacli contract diff my_table.yml # local vs cloud diff
sodacli contract lint my_table.yml # validate syntax (offline)
sodacli contract lint contracts/*.yml # lint multiple files
sodacli contract verify my_table.yml # run checks via cloud Runner
sodacli contract verify my_table.yml # run checks via cloud Runner (local file)
sodacli contract verify datasource/db/schema/table # run checks via cloud Runner (DQN, no local file)
sodacli contract verify my_table.yml --no-wait # fire and forget
sodacli contract verify my_table.yml --local --datasource config.yml # run locally via soda-core
sodacli contract verify my_table.yml --local --datasource config.yml --push # run locally + push results to cloud
Expand Down
2 changes: 1 addition & 1 deletion command_tree.txt
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ sodacli
│ │ # file + prompt → improve existing contract
│ │ # --no-interactive → fails with clear error describing what's missing
│ │ --output <file>
│ ├── verify <file> # verify contract checks against data
│ ├── verify <file|dqn> # verify contract checks against data
│ │ [--datasource <file>] # datasource config file (required with --local)
│ │ [--local] # run locally via soda-core 🔌 [requires soda-core on PATH]
│ │ [--push] # push results to Soda Cloud (useful with --local)
Expand Down
47 changes: 45 additions & 2 deletions go/cmd/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,23 +648,29 @@ func runCopilotImprove(file, prompt string) error {
// ── contract verify ───────────────────────────────────────────────────────────

var contractVerifyCmd = &cobra.Command{
Use: "verify <file>",
Use: "verify <file|dqn>",
Short: "Run contract checks against your data",
Long: `Execute data quality checks defined in a contract file.

By default, pushes the contract to Soda Cloud and triggers verification via a Runner.
Polls for results and displays a summary.
Also accepts a dataset DQN (datasource/db/schema/table) to fetch and verify an existing
contract from Soda Cloud without a local file.

With --local, runs verification locally via soda-core (must be on PATH).
In local mode, --datasource <config.yml> is required.
In local mode, --datasource <config.yml> is required and a contract file is expected.
Use --push to publish local results to Soda Cloud.

Exit codes: 0=all passing, 1=checks failed, 2=error, 3=auth error`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
file := args[0]
local, _ := cmd.Flags().GetBool("local")
isDQN := !strings.HasSuffix(file, ".yml") && !strings.HasSuffix(file, ".yaml")

if local && isDQN {
return output.Errorf(2, "--local requires a contract file (.yml/.yaml), not a dataset DQN")
}
if local {
return runContractVerifyLocal(cmd, file)
}
Expand All @@ -684,6 +690,15 @@ var contractVerifyCmd = &cobra.Command{
// polls for results, and displays a summary. Reused by the verify command
// and both onboard flows.
func runContractVerify(client *api.Client, file string, noWait bool) error {
if strings.HasSuffix(file, ".yml") || strings.HasSuffix(file, ".yaml") {
return runContractVerifyByYAML(client, file, noWait)
}
return runContractVerifyByDQN(client, file, noWait)
}

// runContractVerifyByYAML reads a local contract YAML, pushes it to Soda Cloud,
// triggers verification via a Runner, and polls for results.
func runContractVerifyByYAML(client *api.Client, file string, noWait bool) error {
contents, err := os.ReadFile(file)
if err != nil {
return output.Errorf(2, "could not read file %s: %v", file, err)
Expand Down Expand Up @@ -731,6 +746,34 @@ func runContractVerify(client *api.Client, file string, noWait bool) error {
}
fmt.Println(output.Dim.Render(" Scan ID: " + scanID))

return pollAndDisplayVerification(client, scanID, noWait)
}

// runContractVerifyByDQN looks up an existing contract in Soda Cloud by dataset DQN,
// triggers verification via a Runner, and polls for results. No local file is required.
func runContractVerifyByDQN(client *api.Client, dqn string, noWait bool) error {
fmt.Println(output.Dim.Render(" Looking up contract for " + dqn + "..."))
contract, err := client.FindContractByDataset(dqn)
if err != nil {
return err
}
if contract == nil {
return output.Errorf(2, "no contract found for dataset %s", dqn)
}

// Trigger verification
fmt.Println(output.Dim.Render(" Triggering verification..."))
scanID, err := client.VerifyContract(contract.ID)
if err != nil {
return err
}
fmt.Println(output.Dim.Render(" Scan ID: " + scanID))

return pollAndDisplayVerification(client, scanID, noWait)
}

// pollAndDisplayVerification waits for a scan to complete and prints the results.
func pollAndDisplayVerification(client *api.Client, scanID string, noWait bool) error {
if noWait {
output.PrintSuccess(fmt.Sprintf("Verification started (scan: %s). Running in background.", scanID), GCtx)
fmt.Println(output.Dim.Render(" Check status: sodacli job logs " + scanID))
Expand Down
29 changes: 29 additions & 0 deletions go/tests/integration/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,35 @@ func TestContractVerify(t *testing.T) {
})
}

func TestContractVerifyDQN(t *testing.T) {
t.Run("local_rejects_dqn", func(t *testing.T) {
// Error path — no credentials needed.
r := run(t, "contract", "verify", "datasource/db/schema/table", "--local", "--datasource", "ds.yml")
assertExitCode(t, r, 2)
assertOutputContains(t, r, "--local requires a contract file")
})

t.Run("nonexistent_dqn", func(t *testing.T) {
skipIfNoCredentials(t)
loginForTest(t)
r := run(t, "contract", "verify", "fake/ds/no/exist", "--no-wait")
assertExitCode(t, r, 2)
assertOutputContains(t, r, "no contract found")
})

t.Run("verify_by_dqn", func(t *testing.T) {
skipIfNoCredentials(t)
dqn := testDatasetDQN()
if dqn == "" {
t.Skip("SODA_TEST_DATASET_DQN not set")
}
loginForTest(t)
r := run(t, "contract", "verify", dqn, "--no-wait")
assertExitCode(t, r, 0)
assertOutputContains(t, r, "Verification started")
})
}

func TestContractVerifyLocal(t *testing.T) {
// Local verify error paths don't need credentials.

Expand Down
1 change: 1 addition & 0 deletions go/tests/integration/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func testKeySecret() string { return env("SODA_TEST_API_KEY_SECRET") }
func testDatasourceID() string { return env("SODA_TEST_DATASOURCE_ID") }
func testDatasourceName() string { return env("SODA_TEST_DATASOURCE_NAME") }
func testDatasetID() string { return env("SODA_TEST_DATASET_ID") }
func testDatasetDQN() string { return env("SODA_TEST_DATASET_DQN") }
func testDSConfig() string {
v := env("SODA_TEST_DS_CONFIG")
if v == "" {
Expand Down
5 changes: 4 additions & 1 deletion skills/soda-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,12 @@ sodacli contract diff my_table.yml
sodacli contract lint my_table.yml
sodacli contract lint contracts/*.yml # glob support

# Run checks via cloud Runner
# Run checks via cloud Runner (local file)
sodacli contract verify my_table.yml --output json

# Run checks via cloud Runner using dataset DQN — no local file needed
sodacli contract verify datasource/db/schema/table --output json

# Run checks locally via soda-core (no cloud auth needed)
sodacli contract verify my_table.yml --local --datasource datasource.yml

Expand Down