Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background.
- GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed.
- Settings > AI reading sign-in tokens and running command line tools for providers that were never added.
- View DDL shown as a made-up CREATE TABLE on SQL Server, Oracle, Dameng, DuckDB, Redshift and Cassandra. (#3402)
- Show DDL and Copy DDL failing on every view on SQLite, libSQL, Cloudflare D1, SAP HANA, Spanner, Trino and Teradata.
- SQL Server view definitions naming the view's old name after a rename, and blank for an encrypted view or a name with a period.
- SQL Server Edit View Definition template creating the view in the default schema instead of the view's own.
- Oracle view DDL losing the view's column names, and cut off after 4000 bytes without saying so.
- Trino materialized views left out of SQL exports.

### Security

Expand Down
2 changes: 1 addition & 1 deletion Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ let package = Package(
),
.target(
name: "TableProMSSQLCore",
dependencies: ["TableProCoreTypes"],
dependencies: ["TableProCoreTypes", "TableProSQLGrammar"],
path: "Sources/TableProMSSQLCore"
),
.target(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import Foundation
import TableProSQLGrammar

/// The object name a stored module's `CREATE` header declares.
///
/// SQL Server keeps the batch that created a view, routine or trigger as it was sent, and `sp_rename` renames the
/// object without touching that text. Measured on Azure SQL Edge 15.0: after `sp_rename 'dbo.v_old', 'v_new'` the
/// definition still reads `CREATE VIEW dbo.v_old AS ...`, so the text read back creates the old name. SSMS rewrites
/// the name in the header when it scripts the object, and so does this. The header is found with the shared lexer,
/// because the stored batch keeps its leading comments, and a comment can read `CREATE VIEW` too.
public struct MSSQLModuleHeader: Sendable, Equatable {
/// Where the whole name sits in the definition, in UTF-16 units, from its first part to its last.
public var nameRange: NSRange {
guard let first = partRanges.first, let last = partRanges.last else { return NSRange(location: 0, length: 0) }
return NSRange(location: first.location, length: NSMaxRange(last) - first.location)
}

/// The name's parts without their quotes, the schema first when the header names one.
public let nameParts: [String]

/// Where each part sits, quotes included, so a rename replaces the parts and keeps what lies
/// between them.
public let partRanges: [NSRange]

/// The header of `definition`, which must open with `CREATE [OR ALTER] <objectKeyword>` or
/// `ALTER <objectKeyword>`, or nil when it does not or names its object with more than two parts.
public static func parse(_ definition: String, objectKeyword: String) -> MSSQLModuleHeader? {
var cursor = Cursor(text: definition as NSString)
cursor.skipTrivia()
if cursor.keyword("CREATE") {
cursor.skipTrivia()
if cursor.keyword("OR") {
cursor.skipTrivia()
guard cursor.keyword("ALTER") else { return nil }
cursor.skipTrivia()
}
} else if cursor.keyword("ALTER") {
cursor.skipTrivia()
} else {
return nil
}
guard cursor.keyword(objectKeyword) else { return nil }
cursor.skipTrivia()

var parts: [String] = []
var ranges: [NSRange] = []
while true {
let start = cursor.index
guard let part = cursor.identifier() else { return nil }
parts.append(part)
ranges.append(NSRange(location: start, length: cursor.index - start))
cursor.skipTrivia()
guard cursor.consumePeriod() else { break }
cursor.skipTrivia()
}
guard (1...2).contains(parts.count) else { return nil }
return MSSQLModuleHeader(nameParts: parts, partRanges: ranges)
}

/// `definition` with its header naming the object as it is named now. Only a stale part is
/// replaced, so a comment between the parts stays, and a stale unqualified name gains its schema.
/// A header that already names the object is left exactly as it was written.
public static func namingObject(
in definition: String,
objectKeyword: String,
schema: String,
name: String
) -> String {
guard let header = parse(definition, objectKeyword: objectKeyword),
let nameRange = header.partRanges.last
else { return definition }
let text = definition as NSString
let bracketedName = "[\(MSSQLSchemaQueries.escapeBracket(name))]"
let bracketedSchema = "[\(MSSQLSchemaQueries.escapeBracket(schema))]"

guard header.nameParts.count == 2, let schemaRange = header.partRanges.first else {
guard header.nameParts.last != name else { return definition }
return text.replacingCharacters(in: nameRange, with: "\(bracketedSchema).\(bracketedName)")
}
var renamed = definition
if header.nameParts[1] != name {
renamed = (renamed as NSString).replacingCharacters(in: nameRange, with: bracketedName)
}
if header.nameParts[0] != schema {
renamed = (renamed as NSString).replacingCharacters(in: schemaRange, with: bracketedSchema)
}
return renamed
}
}

private struct Cursor {
private static let period = UInt16(UnicodeScalar(".").value)
private static let openBracket = UInt16(UnicodeScalar("[").value)
private static let identifierSymbols: Set<UInt16> = Set("_@#$".utf16)

let text: NSString
let length: Int
var index = 0

init(text: NSString) {
self.text = text
self.length = text.length
}

mutating func skipTrivia() {
while index < length {
if SqlLexer.isWhitespace(text.character(at: index)) {
index += 1
continue
}
guard let span = SQLNonCodeSpan.span(at: index, in: text, grammar: SQLLexicalProfile.sqlServer),
span.kind.isComment
else { return }
index = max(span.end, index + 1)
}
}

mutating func keyword(_ expected: String) -> Bool {
let start = index
guard let word = word(), word.caseInsensitiveCompare(expected) == .orderedSame else {
index = start
return false
}
return true
}

mutating func consumePeriod() -> Bool {
guard index < length, text.character(at: index) == Self.period else { return false }
index += 1
return true
}

/// One part of a name: `[a]]b]`, `"a""b"` or a regular identifier.
mutating func identifier() -> String? {
guard index < length else { return nil }
let opener = text.character(at: index)
guard opener == Self.openBracket || opener == SqlLexer.doubleQuote else { return word() }
guard let span = SQLNonCodeSpan.span(at: index, in: text, grammar: SQLLexicalProfile.sqlServer),
span.kind == .quoted, span.isTerminated
else { return nil }
let body = text.substring(with: NSRange(location: span.start + 1, length: span.contentEnd - span.start - 1))
index = span.end
return opener == Self.openBracket
? body.replacingOccurrences(of: "]]", with: "]", options: .literal)
: body.replacingOccurrences(of: "\"\"", with: "\"", options: .literal)
}

private mutating func word() -> String? {
let start = index
while index < length, isIdentifierUnit(text.character(at: index)) {
index += 1
}
guard index > start else { return nil }
return text.substring(with: NSRange(location: start, length: index - start))
}

private func isIdentifierUnit(_ unit: UInt16) -> Bool {
SQLNonCodeSpan.isWordUnit(unit) || Self.identifierSymbols.contains(unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ public enum MSSQLSchemaQueries {
MSSQLStringLiteral.escaped(value)
}

/// By code unit, as ``MSSQLStringLiteral`` escapes a quote: a default replacement matches whole
/// grapheme clusters, so a `]` followed by a combining mark was never doubled and closed the
/// identifier early.
public static func escapeBracket(_ value: String) -> String {
value.replacingOccurrences(of: "]", with: "]]")
value.replacingOccurrences(of: "]", with: "]]", options: .literal)
}

public static func bracketed(schema: String, table: String) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public struct SQLLexicalProfile: Sendable, Hashable {
/// A `GO` line is sqlcmd's, not the server's: sent to it, Azure SQL Edge answers Msg 102 and runs nothing. A
/// statement needs no `;`: `SELECT 1` followed by `DROP TABLE t` runs both. A `MERGE` is the exception, and one
/// sent without its `;` fails the whole batch with Msg 10713.
static let sqlServer: SQLLexicalGrammar = [
public static let sqlServer: SQLLexicalGrammar = [
.bracketQuotedIdentifiers, .doubledClosingBracketEscapes, .nestedBlockComments,
.carriageReturnEndsLineComments, .batchSeparatorLines, .unterminatedStatements, .terminatedMergeStatements,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public enum TeradataSchemaQueries {
"SHOW TABLE \(qualifiedName(database: database, table: table))"
}

public static func tableKind(database: String, table: String) -> String {
"""
SELECT TableKind FROM DBC.TablesV \
WHERE DatabaseName = \(quoteLiteral(database)) AND TableName = \(quoteLiteral(table))
"""
}

public static func viewDefinition(database: String, view: String) -> String {
"""
SELECT RequestText FROM DBC.TablesV \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ public enum TrinoIntrospectionSQL {
"""
}

/// One relation's `table_type`, which says `VIEW` for a view. A materialized view is not told
/// apart here: ``materializedView(catalog:schema:name:)`` answers for it.
public static func relationType(catalog: String, schema: String, table: String) -> String {
"""
SELECT table_type FROM \(quoteIdentifier(catalog)).information_schema.tables \
WHERE table_schema = \(quoteLiteral(schema)) AND table_name = \(quoteLiteral(table))
"""
}

public static func materializedView(catalog: String, schema: String, name: String) -> String {
listMaterializedViews(catalog: catalog, schema: schema) + " AND name = \(quoteLiteral(name))"
}

public static func approximateRowCount(catalog: String?, schema: String?, table: String) -> String {
"SHOW STATS FOR \(qualifiedName(catalog: catalog, schema: schema, table: table))"
}
Expand All @@ -79,4 +92,8 @@ public enum TrinoIntrospectionSQL {
public static func showCreateView(catalog: String?, schema: String?, view: String) -> String {
"SHOW CREATE VIEW \(qualifiedName(catalog: catalog, schema: schema, table: view))"
}

public static func showCreateMaterializedView(catalog: String?, schema: String?, view: String) -> String {
"SHOW CREATE MATERIALIZED VIEW \(qualifiedName(catalog: catalog, schema: schema, table: view))"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
@testable import TableProMSSQLCore
import XCTest

final class MSSQLModuleHeaderTests: XCTestCase {
func testReadsATwoPartName() throws {
let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW dbo.v AS SELECT 1 AS a", objectKeyword: "VIEW"))
XCTAssertEqual(header.nameParts, ["dbo", "v"])
XCTAssertEqual(header.nameRange, NSRange(location: 12, length: 5))
}

/// The stored batch keeps the comments before the header, and a comment can name another object.
func testSkipsLeadingCommentsThatReadLikeAHeader() throws {
let definition = "-- CREATE VIEW dbo.other AS\r\n/* outer /* nested */ CREATE VIEW x */\ncreate view [dbo].[v_dscp1]\r\nAS\r\nSELECT 1"
let header = try XCTUnwrap(MSSQLModuleHeader.parse(definition, objectKeyword: "VIEW"))
XCTAssertEqual(header.nameParts, ["dbo", "v_dscp1"])
XCTAssertEqual((definition as NSString).substring(with: header.nameRange), "[dbo].[v_dscp1]")
}

func testUnquotesBracketedAndDoubleQuotedParts() throws {
let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW [we]]ird].\"a\"\"b\" AS SELECT 1", objectKeyword: "VIEW"))
XCTAssertEqual(header.nameParts, ["we]ird", "a\"b"])
}

/// A period inside brackets is part of the name, not a separator.
func testKeepsAPeriodInsideBrackets() throws {
let header = try XCTUnwrap(MSSQLModuleHeader.parse("CREATE VIEW sales.[v.1] AS SELECT 1", objectKeyword: "VIEW"))
XCTAssertEqual(header.nameParts, ["sales", "v.1"])
}

func testAllowsCommentsAndSpacesAroundTheParts() throws {
let definition = "/*X*/ CrEaTe /*Y*/ VIEW dbo . v(a) AS SELECT 1 AS a;"
let header = try XCTUnwrap(MSSQLModuleHeader.parse(definition, objectKeyword: "VIEW"))
XCTAssertEqual(header.nameParts, ["dbo", "v"])
XCTAssertEqual((definition as NSString).substring(with: header.nameRange), "dbo . v")
}

func testAcceptsCreateOrAlterAndAlter() {
XCTAssertEqual(MSSQLModuleHeader.parse("CREATE OR ALTER VIEW v AS SELECT 1", objectKeyword: "VIEW")?.nameParts, ["v"])
XCTAssertEqual(MSSQLModuleHeader.parse("ALTER VIEW s.v AS SELECT 1", objectKeyword: "VIEW")?.nameParts, ["s", "v"])
}

func testRefusesTextThatIsNotThatHeader() {
XCTAssertNil(MSSQLModuleHeader.parse("SELECT 1", objectKeyword: "VIEW"))
XCTAssertNil(MSSQLModuleHeader.parse("CREATE TABLE t (a int)", objectKeyword: "VIEW"))
XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEWS v AS SELECT 1", objectKeyword: "VIEW"))
XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEW db.dbo.v AS SELECT 1", objectKeyword: "VIEW"))
XCTAssertNil(MSSQLModuleHeader.parse("CREATE VIEW [unterminated AS SELECT 1", objectKeyword: "VIEW"))
XCTAssertNil(MSSQLModuleHeader.parse("", objectKeyword: "VIEW"))
}

/// Measured: after `sp_rename '[dbo].[v_old]', 'v_new'` the stored text still reads `CREATE VIEW dbo.v_old`.
func testRenamesAStaleName() {
let renamed = MSSQLModuleHeader.namingObject(
in: "CREATE VIEW dbo.v_old AS SELECT 2 AS y\n",
objectKeyword: "VIEW",
schema: "dbo",
name: "v_new"
)
XCTAssertEqual(renamed, "CREATE VIEW dbo.[v_new] AS SELECT 2 AS y\n")
}

func testRenamesAStaleSchema() {
let moved = MSSQLModuleHeader.namingObject(
in: "CREATE VIEW dbo.v AS SELECT 1", objectKeyword: "VIEW", schema: "sales", name: "v"
)
XCTAssertEqual(moved, "CREATE VIEW [sales].v AS SELECT 1")
}

func testKeepsWhatLiesBetweenTheParts() {
let renamed = MSSQLModuleHeader.namingObject(
in: "CREATE VIEW dbo /* keep this */ . old_name AS SELECT 1 AS n",
objectKeyword: "VIEW",
schema: "dbo",
name: "new_name"
)
XCTAssertEqual(renamed, "CREATE VIEW dbo /* keep this */ . [new_name] AS SELECT 1 AS n")

let both = MSSQLModuleHeader.namingObject(
in: "CREATE VIEW dbo/*a*/./*b*/old AS SELECT 1", objectKeyword: "VIEW", schema: "sales", name: "new"
)
XCTAssertEqual(both, "CREATE VIEW [sales]/*a*/./*b*/[new] AS SELECT 1")
}

func testAnUnqualifiedStaleNameGainsItsSchema() {
let renamed = MSSQLModuleHeader.namingObject(
in: "CREATE VIEW v_old AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "v_new"
)
XCTAssertEqual(renamed, "CREATE VIEW [dbo].[v_new] AS SELECT 1")
}

func testLeavesACurrentHeaderAsWritten() {
let definitions = [
"-- owner: finance\r\ncreate view [dbo].[v] AS SELECT 1 -- trailing note\r\n",
"CREATE VIEW v AS SELECT 1",
"CREATE VIEW dbo.v AS SELECT 1",
]
for definition in definitions {
XCTAssertEqual(
MSSQLModuleHeader.namingObject(in: definition, objectKeyword: "VIEW", schema: "dbo", name: "v"),
definition
)
}
}

func testEscapesAClosingBracketInTheNewName() {
let renamed = MSSQLModuleHeader.namingObject(
in: "CREATE VIEW dbo.v AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "a]b"
)
XCTAssertEqual(renamed, "CREATE VIEW dbo.[a]]b] AS SELECT 1")
}

func testANewNameCannotCloseItsBracketsEarly() {
let renamed = MSSQLModuleHeader.namingObject(
in: "CREATE VIEW dbo.a AS SELECT 1", objectKeyword: "VIEW", schema: "dbo", name: "v]\u{0301}\nGO\nDROP TABLE t"
)
XCTAssertEqual(renamed, "CREATE VIEW dbo.[v]]\u{0301}\nGO\nDROP TABLE t] AS SELECT 1")
}

func testLeavesUnreadableTextAlone() {
let text = "SELECT 1"
XCTAssertEqual(MSSQLModuleHeader.namingObject(in: text, objectKeyword: "VIEW", schema: "dbo", name: "v"), text)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ final class MSSQLSchemaQueriesTests: XCTestCase {
XCTAssertEqual(MSSQLSchemaQueries.escapeBracket("weird]name"), "weird]]name")
}

/// A name `sp_rename` accepted verbatim must not close its brackets early and start a new batch.
func testEscapeBracketDoublesABracketBeforeACombiningMark() {
XCTAssertEqual(MSSQLSchemaQueries.escapeBracket("v]\u{0301}\nGO"), "v]]\u{0301}\nGO")
XCTAssertEqual(
MSSQLSchemaQueries.bracketed(schema: "dbo", table: "v]\u{0301}"),
"[dbo].[v]]\u{0301}]"
)
}

func testBracketedComposesIdentifier() {
XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "dbo", table: "Users"), "[dbo].[Users]")
XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "weird]", table: "x"), "[weird]]].[x]")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,11 @@ final class TeradataSchemaQueriesTests: XCTestCase {
XCTAssertEqual(TeradataSchemaQueries.quoteLiteral("a'\u{0301}b"), "'a''\u{0301}b'")
XCTAssertEqual(TeradataSchemaQueries.quoteIdentifier("a\"\u{0301}b"), "\"a\"\"\u{0301}b\"")
}

func testTableKindReadsOneObject() {
let sql = TeradataSchemaQueries.tableKind(database: "Retail", table: "o'v")
XCTAssertTrue(sql.hasPrefix("SELECT TableKind FROM DBC.TablesV"))
XCTAssertTrue(sql.contains("DatabaseName = 'Retail'"))
XCTAssertTrue(sql.contains("TableName = 'o''v'"))
}
}
Loading
Loading