Skip to content

fix(studio): resolve SQL-console mutation PK from declared metadata, drop the 'id'-name guess - #218

Merged
remcostoeten merged 1 commit into
masterfrom
fix/sql-console-pk-resolution
Jul 26, 2026
Merged

fix(studio): resolve SQL-console mutation PK from declared metadata, drop the 'id'-name guess#218
remcostoeten merged 1 commit into
masterfrom
fix/sql-console-pk-resolution

Conversation

@remcostoeten

@remcostoeten remcostoeten commented Jul 26, 2026

Copy link
Copy Markdown
Owner

What

SQL-console cell edit / row delete used to pick its WHERE key by falling back to any column named id, case-insensitively, with no uniqueness check. On a table without a declared primary key but with a non-unique id-named column (staging/join/log tables), editing one cell silently updated every matching row, and deleting one row deleted all of them.

How

  • New pure, unit-tested resolveMutationPrimaryKey() in packages/studio/src/features/sql-console/mutation-primary-key.ts (mirrors the existing query-target.ts pattern). It only trusts declared PK metadata:
    1. a single PK declared in the result's own column definitions, else
    2. the loaded database schema for the query's extracted source table (schema-qualified names matched case-insensitively).
  • The resolved PK must actually be present in the result columns, otherwise mutations are disabled with a message telling the user to include it in the SELECT.
  • Composite PKs stay disabled (now caught from schema metadata too, not just result metadata).
  • sql-results.tsx loses the duplicated inline derivation; sql-console.tsx passes its already-loaded tables schema down.

Net effect: mutations now also work on tables whose PK isn't named id (previously impossible, since engines return result columns as bare name strings), and never fire on an unverified id column.

Verification

  • 12 new unit tests in __tests__/apps/desktop/src/features/sql-console/mutation-primary-key.test.ts
  • bun run test:desktop: 789 passed
  • bun run lint, studio + desktop typecheck: clean

Closes #207

Summary by Sourcery

Resolve SQL-console row mutation primary key strictly from declared metadata and disable unsafe mutations when no reliable primary key is available.

Bug Fixes:

  • Prevent SQL-console edits and deletes from using a non-unique column named "id" as a fallback primary key, avoiding unintended bulk updates and deletes.

Enhancements:

  • Introduce a shared mutation primary key resolution helper that consults result metadata and loaded schema, including schema-qualified tables, and surfaces clear disablement reasons when mutations are not supported.

Tests:

  • Add unit test coverage for mutation primary key resolution across declared, schema-derived, composite, missing, and case-insensitive primary key scenarios.

Summary by CodeRabbit

  • New Features

    • Improved SQL Console row editing and deletion safety by reliably identifying table primary keys.
    • Supports primary-key information from query results or database schema metadata.
    • Handles schema-qualified tables and case differences in primary-key names.
  • Bug Fixes

    • Prevents mutations when primary keys are missing, composite, ambiguous, or unavailable in the displayed results.
    • Provides clearer reasons when row editing or deletion is disabled.

@sourcery-ai

sourcery-ai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

🧙 Sourcery has finished reviewing your pull request!


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dora Ready Ready Preview, Comment Jul 26, 2026 1:56pm

…drop the 'id'-name guess

The mutation path used to fall back to any column named 'id' as the WHERE key, silently updating/deleting every matching row on tables without a real primary key. The derivation is now a pure, tested resolveMutationPrimaryKey() that only trusts declared PK metadata — from the result's column definitions or from the loaded schema for the extracted source table — and requires the PK column to be present in the result set.

Closes #207
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

SQL console mutation key resolution is extracted into a schema-aware resolver, integrated into result rendering, and covered by tests. Mutations now require a single primary key that exists in the selected result columns.

Changes

SQL mutation safety

Layer / File(s) Summary
Primary-key resolver
packages/studio/src/features/sql-console/mutation-primary-key.ts
Adds typed resolution results, rejects missing or composite keys, resolves keys from result metadata or schema tables, and matches result columns case-insensitively.
Results mutation integration
packages/studio/src/features/sql-console/components/sql-results.tsx, packages/studio/src/features/sql-console/sql-console.tsx
Passes schema metadata into SqlResults and uses the resolver to determine mutation availability and disabled reasons.
Resolver validation
__tests__/apps/desktop/src/features/sql-console/mutation-primary-key.test.ts
Tests declared and schema-derived keys, schema-qualified tables, casing, composite keys, missing columns, unknown tables, and rejection of "id" fallback.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning For [#207], the resolver, tests, schema handling, and id fallback removal are implemented, but the affected_rows > 1 safety guard is not shown. Add a row-count check in the write adapters so single-row edit/delete fails or warns when affected_rows > 1, and surface that to the UI.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title concisely describes the SQL-console primary-key resolution change and removal of the id fallback.
Out of Scope Changes check ✅ Passed All changes stay within SQL-console mutation PK resolution, tests, and wiring, with no unrelated feature work evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sql-console-pk-resolution

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/studio/src/features/sql-console/mutation-primary-key.ts (1)

8-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename unexported args type to Props.

Per coding guidelines, an unexported single props/arguments type should be named type Props (or defined inline), not a distinct name like ResolveArgs.

As per coding guidelines, "name it type Props or define it inline instead of naming it ComponentNameProps."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/studio/src/features/sql-console/mutation-primary-key.ts` around
lines 8 - 13, Rename the unexported ResolveArgs type to Props and update all
references to it, preserving its fields and optionality unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/studio/src/features/sql-console/mutation-primary-key.ts`:
- Around line 82-92: Update findSchemaTable to collect all case-insensitive
table-name matches when schemaPart is absent, returning a table only when
exactly one schema-less name match exists; return undefined for multiple matches
so the schema-table primary-key fallback is disabled. Preserve the existing
schema-qualified lookup behavior.

---

Nitpick comments:
In `@packages/studio/src/features/sql-console/mutation-primary-key.ts`:
- Around line 8-13: Rename the unexported ResolveArgs type to Props and update
all references to it, preserving its fields and optionality unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6207dba1-833f-4a5c-b35f-954b567bcbfc

📥 Commits

Reviewing files that changed from the base of the PR and between 34b2b14 and ecd9c38.

📒 Files selected for processing (4)
  • __tests__/apps/desktop/src/features/sql-console/mutation-primary-key.test.ts
  • packages/studio/src/features/sql-console/components/sql-results.tsx
  • packages/studio/src/features/sql-console/mutation-primary-key.ts
  • packages/studio/src/features/sql-console/sql-console.tsx

Comment on lines +82 to +92
function findSchemaTable(sourceTable: string, schemaTables: TableInfo[]): TableInfo | undefined {
const separatorIndex = sourceTable.lastIndexOf('.')
const schemaPart = separatorIndex === -1 ? undefined : sourceTable.slice(0, separatorIndex)
const namePart = separatorIndex === -1 ? sourceTable : sourceTable.slice(separatorIndex + 1)

return schemaTables.find(function (table) {
if (table.name.toLowerCase() !== namePart.toLowerCase()) return false
if (!schemaPart) return true
return (table.schema ?? '').toLowerCase() === schemaPart.toLowerCase()
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'function extractMutationSourceTable' -A 20 packages/studio/src/features/sql-console

Repository: remcostoeten/dora

Length of output: 2145


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files packages/studio/src/features/sql-console | rg '(mutation-primary-key|query-target|sql-console|TableInfo|schemaTables)' || true

echo
echo "== mutation-primary-key outline =="
ast-grep outline packages/studio/src/features/sql-console/mutation-primary-key.ts --view expanded || true

echo
echo "== mutation-primary-key relevant section =="
sed -n '1,180p' packages/studio/src/features/sql-console/mutation-primary-key.ts

echo
echo "== query-target relevant extraction implementation =="
sed -n '1,180p' packages/studio/src/features/sql-console/query-target.ts

echo
echo "== schemaTables/table metadata references =="
rg -n 'schemaTables|TableInfo|refreshSchema|findSchemaTable|extractMutationSourceTable' packages/studio/src/features/sql-console packages/studio/src -g '*.ts' -g '*.tsx'

Repository: remcostoeten/dora

Length of output: 25371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== query-target tests/fixtures =="
fd -i 'query-target|mutation-primary-key|data-provider|mock' packages/studio/src | sed -n '1,120p'

echo
echo "== locate adapters/mock relevant section =="
sed -n '600,660p' packages/studio/src/core/data-provider/adapters/mock.ts

echo
echo "== inspect query-target exports/usages with context =="
rg -n 'extractMutationSourceTable|resolveMutationPrimaryKey' packages/studio/src -g '*.ts' -g '*.tsx' -A5 -B5

echo
echo "== deterministic parser probe =="
node - <<'JS'
function unquoteIdentifierPart(part) {
  const trimmed = part.trim()
  if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
    return trimmed.slice(1, -1).replace(/""/g, '"')
  }
  return trimmed
}
function normalizeIdentifier(identifier) {
  const trimmed = identifier.trim()
  if (!trimmed) return undefined
  const parts = trimmed.split('.').map(unquoteIdentifierPart).filter(Boolean)
  if (parts.length === 0 || parts.length > 2) return undefined
  return parts.join('.')
}
function matchLeadingIdentifier(segment) {
  const match = segment.match(/^\s*((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/)
  if (!match) return undefined
  return normalizeIdentifier(match[1])
}
function extractSingleSelectTable(query) {
  const fromMatch = query.match(/\bfrom\b([\s\S]*)/i)
  if (!fromMatch) return undefined
  const afterFrom = fromMatch[1]
  const boundaryMatch = afterFrom.match(/\b(where|group\s+by|order\s+by|limit|offset|returning|union|intersect|except|having|for)\b/i)
  if (!boundaryMatch || boundaryMatch.index === undefined)
    return afterFrom.trim().replace(/\s+/g, ' ').trim()
  return afterFrom.slice(0, boundaryMatch.index).trim().replace(/\s+/g, ' ').trim()
}
function normalizeQueryForProbe(query) {
  let normalized = query.trim().toUpperCase().replace(/\s+/g, ' ')
  if (normalized.startsWith('SELECT') || normalized.startsWith('WITH')) return query
  let pattern = null
  if (normalized.startsWith('DELETE FROM ')) {
    pattern = /^DELETE FROM ((?:\"[^\"]+\"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:\"[^\"]+\"|[A-Za-z_][\w$]*))?)/i
    const m = query.match(pattern)
    return m ? normalizeIdentifier(m[1]) : undefined
  }
  if (normalized.startsWith('UPDATE ')) return extractSingleSelectTable(query) // no, wait:
}
function extractFromClauseSegmentForProbe(query) {
  const fromMatch = query.match(/\bfrom\b([\s\S]*)/i)
  if (!fromMatch) return undefined
  const afterFrom = fromMatch[1]
  const boundaryMatch = afterFrom.match(/\b(where|group\s+by|order\s+by|limit|offset|returning|union|intersect|except|having|for)\b/i)
  if (!boundaryMatch || boundaryMatch.index === undefined) {
    return afterFrom.trim().replace(/\s+/g, ' ')
  } else {
    return afterFrom.slice(0, boundaryMatch.index).trim().replace(/\s+/g, ' ')
  }
}
function extractSingleDmlTableForProbe(query, pattern) {
  const match = query.match(pattern)
  if (!match) return undefined
  return normalizeIdentifier(match[1])
}

const cases = [
  'SELECT id, name FROM users',
  'SELECT id, name FROM "public".users',
  'DELETE FROM users',
  'DELETE FROM "public".users',
  'UPDATE users SET name = 1'
]
for (const query of cases) {
  const upper = query.trim().toUpperCase()
  let out = 'UNK'
  if (/^SELECT\b|^WITH\b/i.test(query)) {
    out = extractSingleSelectTable(query)
    out += ` [fromSegment:${extractFromClauseSegmentForProbe(query)}]`
  } else if (/^UPDATE\b/i.test(query)) {
    out = extractSingleDmlTableForProbe(query, /^update\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/i)
  } else if (/^DELETE\b/i.test(query)) {
    out = extractSingleDmlTableForProbe(query, /^delete\s+from\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/i)
  }
  console.log(query, '=>', out)
}

function findSchemaTable(sourceTable, schemaTables) {
  const separatorIndex = sourceTable.lastIndexOf('.')
  const schemaPart = separatorIndex === -1 ? undefined : sourceTable.slice(0, separatorIndex)
  const namePart = separatorIndex === -1 ? sourceTable : sourceTable.slice(separatorIndex + 1)

  return schemaTables.find(function (table) {
    if (table.name.toLowerCase() !== namePart.toLowerCase()) return false
    if (!schemaPart) return true
    return (table.schema ?? '').toLowerCase() === schemaPart.toLowerCase()
  })
}

const ambig = [
  {name:'users', schema:'public', columns:[{name:'id', primaryKey:true}]},
  {name:'users', schema:'audit', columns:[{name:'id', primaryKey:false}]},
]
console.log('unqualified sourceTable=', findSchemaTable('users', ambig))
console.log('qualified sourceTable=', findSchemaTable('audit.users', ambig))
JS

Repository: remcostoeten/dora

Length of output: 13088


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== sql-console refreshSchema section =="
sed -n '180,340p' packages/studio/src/features/sql-console/sql-console.tsx

echo
echo "== types TableInfo =="
sed -n '1,80p' packages/studio/src/features/sql-console/types.ts

Repository: remcostoeten/dora

Length of output: 6684


Guard schema-table resolution against duplicate table names.

extractMutationSourceTable can return unqualified names such as UPDATE users or DELETE FROM users. When schemaTables has multiple users tables across different schemas, findSchemaTable() returns the first match by iteration order instead of a table that is actually unambiguously targeted by the query. Treat multiple schema-less name matches as ambiguous and disable the schema-table PK fallback for them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/studio/src/features/sql-console/mutation-primary-key.ts` around
lines 82 - 92, Update findSchemaTable to collect all case-insensitive table-name
matches when schemaPart is absent, returning a table only when exactly one
schema-less name match exists; return undefined for multiple matches so the
schema-table primary-key fallback is disabled. Preserve the existing
schema-qualified lookup behavior.

@remcostoeten
remcostoeten merged commit d454ea8 into master Jul 26, 2026
10 checks passed
@remcostoeten
remcostoeten deleted the fix/sql-console-pk-resolution branch July 26, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(studio): SQL-console mutations fall back to any column named "id" as the primary key

1 participant