Skip to content

Commit d285da4

Browse files
authored
fix(plugins): qualify browse and filter queries with the table schema (#1758)
* fix(plugins): qualify browse and filter queries with the table schema Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC * fix(plugins): qualify write statements and FK navigation with the table schema Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC
1 parent 8789d76 commit d285da4

11 files changed

Lines changed: 244 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
- Raw filters in the data grid now apply on document and key-value databases; the typed text was being dropped before it reached the driver. (#1529)
2525
- Connecting to Oracle no longer crashes the app while reading certain server values during the handshake; a bad packet now fails the connection with an error instead. (#1746)
26+
- Browsing and editing a SQL Server or Oracle table or view outside the default schema no longer fails with "Invalid object name" or writes to the wrong table; data, filter, and save queries now qualify the table with its schema. (#1754)
2627

2728
## [0.52.1] - 2026-06-22
2829

Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,41 @@ public enum MSSQLSchemaQueries {
1313
"[\(escapeBracket(schema))].[\(escapeBracket(table))]"
1414
}
1515

16+
public static func qualifiedName(schema: String?, table: String) -> String {
17+
guard let schema, !schema.isEmpty else {
18+
return "[\(escapeBracket(table))]"
19+
}
20+
return bracketed(schema: schema, table: table)
21+
}
22+
23+
public static func browse(
24+
schema: String?,
25+
table: String,
26+
orderByClause: String,
27+
offset: Int,
28+
limit: Int
29+
) -> String {
30+
let target = qualifiedName(schema: schema, table: table)
31+
return "SELECT * FROM \(target) \(orderByClause) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY"
32+
}
33+
34+
public static func filtered(
35+
schema: String?,
36+
table: String,
37+
whereClause: String,
38+
orderByClause: String,
39+
offset: Int,
40+
limit: Int
41+
) -> String {
42+
let target = qualifiedName(schema: schema, table: table)
43+
var query = "SELECT * FROM \(target)"
44+
if !whereClause.isEmpty {
45+
query += " WHERE \(whereClause)"
46+
}
47+
query += " \(orderByClause) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY"
48+
return query
49+
}
50+
1651
public static let currentSchema = "SELECT SCHEMA_NAME()"
1752
public static let serverVersion = "SELECT @@VERSION"
1853
public static let beginTransaction = "BEGIN TRANSACTION"

Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,57 @@ final class MSSQLSchemaQueriesTests: XCTestCase {
110110
XCTAssertEqual(parsed?.referencedTable, "users")
111111
XCTAssertEqual(parsed?.referencedColumn, "id")
112112
}
113+
114+
func testQualifiedNamePrefixesSchema() {
115+
XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: "sales", table: "routeCache"), "[sales].[routeCache]")
116+
}
117+
118+
func testQualifiedNameOmitsSchemaWhenNilOrEmpty() {
119+
XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: nil, table: "routeCache"), "[routeCache]")
120+
XCTAssertEqual(MSSQLSchemaQueries.qualifiedName(schema: "", table: "routeCache"), "[routeCache]")
121+
}
122+
123+
func testBrowseQualifiesNonDefaultSchema() {
124+
let sql = MSSQLSchemaQueries.browse(
125+
schema: "sales", table: "routeCache",
126+
orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200
127+
)
128+
XCTAssertEqual(
129+
sql,
130+
"SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY"
131+
)
132+
}
133+
134+
func testBrowseWithoutSchemaStaysUnqualified() {
135+
let sql = MSSQLSchemaQueries.browse(
136+
schema: nil, table: "routeCache",
137+
orderByClause: "ORDER BY (SELECT NULL)", offset: 10, limit: 50
138+
)
139+
XCTAssertEqual(
140+
sql,
141+
"SELECT * FROM [routeCache] ORDER BY (SELECT NULL) OFFSET 10 ROWS FETCH NEXT 50 ROWS ONLY"
142+
)
143+
}
144+
145+
func testFilteredQualifiesSchemaAndAppendsWhere() {
146+
let sql = MSSQLSchemaQueries.filtered(
147+
schema: "sales", table: "routeCache", whereClause: "[id] = 1",
148+
orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200
149+
)
150+
XCTAssertEqual(
151+
sql,
152+
"SELECT * FROM [sales].[routeCache] WHERE [id] = 1 ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY"
153+
)
154+
}
155+
156+
func testFilteredOmitsWhenWhereClauseEmpty() {
157+
let sql = MSSQLSchemaQueries.filtered(
158+
schema: "sales", table: "routeCache", whereClause: "",
159+
orderByClause: "ORDER BY (SELECT NULL)", offset: 0, limit: 200
160+
)
161+
XCTAssertEqual(
162+
sql,
163+
"SELECT * FROM [sales].[routeCache] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY"
164+
)
165+
}
113166
}

Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,24 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
313313
deletedRowIndices: Set<Int>,
314314
insertedRowIndices: Set<Int>
315315
) -> [(statement: String, parameters: [PluginCellValue])]? {
316+
generateStatements(
317+
table: table, schema: nil, columns: columns, primaryKeyColumns: primaryKeyColumns,
318+
changes: changes, insertedRowData: insertedRowData,
319+
deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices
320+
)
321+
}
322+
323+
func generateStatements(
324+
table: String,
325+
schema: String?,
326+
columns: [String],
327+
primaryKeyColumns: [String],
328+
changes: [PluginRowChange],
329+
insertedRowData: [Int: [PluginCellValue]],
330+
deletedRowIndices: Set<Int>,
331+
insertedRowIndices: Set<Int>
332+
) -> [(statement: String, parameters: [PluginCellValue])]? {
333+
let qualifiedTable = MSSQLSchemaQueries.qualifiedName(schema: schema, table: table)
316334
var statements: [(statement: String, parameters: [PluginCellValue])] = []
317335

318336
var deleteChanges: [PluginRowChange] = []
@@ -322,13 +340,15 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
322340
case .insert:
323341
guard insertedRowIndices.contains(change.rowIndex) else { continue }
324342
if let values = insertedRowData[change.rowIndex] {
325-
if let stmt = generateMssqlInsert(table: table, columns: columns, values: values) {
343+
if let stmt = generateMssqlInsert(
344+
table: table, qualifiedTable: qualifiedTable, columns: columns, values: values
345+
) {
326346
statements.append(stmt)
327347
}
328348
}
329349
case .update:
330350
if let stmt = generateMssqlUpdate(
331-
table: table, columns: columns,
351+
qualifiedTable: qualifiedTable, columns: columns,
332352
primaryKeyColumns: primaryKeyColumns, change: change
333353
) {
334354
statements.append(stmt)
@@ -342,7 +362,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
342362
if !deleteChanges.isEmpty {
343363
for change in deleteChanges {
344364
if let stmt = generateMssqlDelete(
345-
table: table, columns: columns,
365+
qualifiedTable: qualifiedTable, columns: columns,
346366
primaryKeyColumns: primaryKeyColumns, change: change
347367
) {
348368
statements.append(stmt)
@@ -355,6 +375,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
355375

356376
private func generateMssqlInsert(
357377
table: String,
378+
qualifiedTable: String,
358379
columns: [String],
359380
values: [PluginCellValue]
360381
) -> (statement: String, parameters: [PluginCellValue])? {
@@ -378,21 +399,19 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
378399

379400
let columnList = nonDefaultColumns.joined(separator: ", ")
380401
let placeholders = parameters.map { _ in "?" }.joined(separator: ", ")
381-
let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]"
382-
let sql = "INSERT INTO \(escapedTable) (\(columnList)) VALUES (\(placeholders))"
402+
let sql = "INSERT INTO \(qualifiedTable) (\(columnList)) VALUES (\(placeholders))"
383403
return (statement: sql, parameters: parameters)
384404
}
385405

386406
private func generateMssqlUpdate(
387-
table: String,
407+
qualifiedTable: String,
388408
columns: [String],
389409
primaryKeyColumns: [String],
390410
change: PluginRowChange
391411
) -> (statement: String, parameters: [PluginCellValue])? {
392412
guard !change.cellChanges.isEmpty else { return nil }
393413
guard let originalRow = change.originalRow else { return nil }
394414

395-
let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]"
396415
var parameters: [PluginCellValue] = []
397416

398417
let setClauses = change.cellChanges.map { cellChange -> String in
@@ -422,19 +441,18 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
422441

423442
let whereClause = conditions.joined(separator: " AND ")
424443
let topClause = primaryKeyColumns.isEmpty ? "TOP (1) " : ""
425-
let sql = "UPDATE \(topClause)\(escapedTable) SET \(setClauses) WHERE \(whereClause)"
444+
let sql = "UPDATE \(topClause)\(qualifiedTable) SET \(setClauses) WHERE \(whereClause)"
426445
return (statement: sql, parameters: parameters)
427446
}
428447

429448
private func generateMssqlDelete(
430-
table: String,
449+
qualifiedTable: String,
431450
columns: [String],
432451
primaryKeyColumns: [String],
433452
change: PluginRowChange
434453
) -> (statement: String, parameters: [PluginCellValue])? {
435454
guard let originalRow = change.originalRow else { return nil }
436455

437-
let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]"
438456
var parameters: [PluginCellValue] = []
439457
var conditions: [String] = []
440458

@@ -458,7 +476,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
458476

459477
let whereClause = conditions.joined(separator: " AND ")
460478
let topClause = primaryKeyColumns.isEmpty ? "TOP (1) " : ""
461-
let sql = "DELETE \(topClause)FROM \(escapedTable) WHERE \(whereClause)"
479+
let sql = "DELETE \(topClause)FROM \(qualifiedTable) WHERE \(whereClause)"
462480
return (statement: sql, parameters: parameters)
463481
}
464482

@@ -562,13 +580,26 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
562580
limit: Int,
563581
offset: Int
564582
) -> String? {
565-
let quotedTable = mssqlQuoteIdentifier(table)
566-
var query = "SELECT * FROM \(quotedTable)"
583+
buildBrowseQuery(
584+
table: table, schema: nil, sortColumns: sortColumns,
585+
columns: columns, limit: limit, offset: offset
586+
)
587+
}
588+
589+
func buildBrowseQuery(
590+
table: String,
591+
schema: String?,
592+
sortColumns: [(columnIndex: Int, ascending: Bool)],
593+
columns: [String],
594+
limit: Int,
595+
offset: Int
596+
) -> String? {
567597
let orderBy = PluginSQLFilter.buildOrderByClause(
568598
sortColumns: sortColumns, columns: columns, quoteIdentifier: mssqlQuoteIdentifier
569599
) ?? "ORDER BY (SELECT NULL)"
570-
query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY"
571-
return query
600+
return MSSQLSchemaQueries.browse(
601+
schema: schema, table: table, orderByClause: orderBy, offset: offset, limit: limit
602+
)
572603
}
573604

574605
func buildFilteredQuery(
@@ -580,8 +611,22 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
580611
limit: Int,
581612
offset: Int
582613
) -> String? {
583-
let quotedTable = mssqlQuoteIdentifier(table)
584-
var query = "SELECT * FROM \(quotedTable)"
614+
buildFilteredQuery(
615+
table: table, schema: nil, filters: filters, logicMode: logicMode,
616+
sortColumns: sortColumns, columns: columns, limit: limit, offset: offset
617+
)
618+
}
619+
620+
func buildFilteredQuery(
621+
table: String,
622+
schema: String?,
623+
filters: [(column: String, op: String, value: String)],
624+
logicMode: String,
625+
sortColumns: [(columnIndex: Int, ascending: Bool)],
626+
columns: [String],
627+
limit: Int,
628+
offset: Int
629+
) -> String? {
585630
let whereClause = PluginSQLFilter.buildWhereClause(
586631
filters: filters,
587632
logicMode: logicMode,
@@ -591,14 +636,13 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
591636
"\(quoted) LIKE '%\(value.replacingOccurrences(of: "'", with: "''"))%'"
592637
}
593638
)
594-
if !whereClause.isEmpty {
595-
query += " WHERE \(whereClause)"
596-
}
597639
let orderBy = PluginSQLFilter.buildOrderByClause(
598640
sortColumns: sortColumns, columns: columns, quoteIdentifier: mssqlQuoteIdentifier
599641
) ?? "ORDER BY (SELECT NULL)"
600-
query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY"
601-
return query
642+
return MSSQLSchemaQueries.filtered(
643+
schema: schema, table: table, whereClause: whereClause,
644+
orderByClause: orderBy, offset: offset, limit: limit
645+
)
602646
}
603647

604648
// MARK: - Query Building Helpers

0 commit comments

Comments
 (0)