Skip to content

Commit 42e28e0

Browse files
authored
source-mssql: Add SQL Server identifier quoting to fix reserved keyword issues (#69248)
1 parent 26274ba commit 42e28e0

File tree

4 files changed

+53
-21
lines changed

4 files changed

+53
-21
lines changed

airbyte-integrations/connectors/source-mssql/metadata.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ data:
99
connectorSubtype: database
1010
connectorType: source
1111
definitionId: b5ea17b1-f170-46dc-bc31-cc744ca984c1
12-
dockerImageTag: 4.3.0-rc.4
12+
dockerImageTag: 4.3.0-rc.5
1313
dockerRepository: airbyte/source-mssql
1414
documentationUrl: https://docs.airbyte.com/integrations/sources/mssql
1515
githubIssueLabel: source-mssql

airbyte-integrations/connectors/source-mssql/src/main/kotlin/io/airbyte/integrations/source/mssql/MsSqlSourceOperations.kt

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,17 +297,31 @@ class MsSqlSourceOperations :
297297
}
298298
}
299299

300-
fun Field.sql(): String = if (type is MsSqlServerHierarchyFieldType) "$id.ToString()" else "$id"
300+
/**
301+
* Quotes an identifier for SQL Server using square brackets. If the identifier contains a
302+
* closing bracket ']', it will be escaped as ']]'. This protects against reserved keywords and
303+
* special characters in identifier names.
304+
*/
305+
fun String.quoted(): String = "[${this.replace("]", "]]")}]"
306+
307+
fun Field.sql(): String =
308+
if (type is MsSqlServerHierarchyFieldType) "${id.quoted()}.ToString()" else id.quoted()
301309

302310
fun FromNode.sql(): String =
303311
when (this) {
304312
NoFrom -> ""
305-
is From -> if (this.namespace == null) "FROM $name" else "FROM $namespace.$name"
313+
is From -> {
314+
val ns = this.namespace
315+
if (ns == null) "FROM ${name.quoted()}" else "FROM ${ns.quoted()}.${name.quoted()}"
316+
}
306317
is FromSample -> {
318+
val ns = this.namespace
307319
if (sampleRateInv == 1L) {
308-
if (namespace == null) "FROM $name" else "FROM $namespace.$name"
320+
if (ns == null) "FROM ${name.quoted()}"
321+
else "FROM ${ns.quoted()}.${name.quoted()}"
309322
} else {
310-
val tableName = if (namespace == null) name else "$namespace.$name"
323+
val tableName =
324+
if (ns == null) name.quoted() else "${ns.quoted()}.${name.quoted()}"
311325
val samplePercent = sampleRatePercentage.toPlainString()
312326

313327
"FROM (SELECT TOP $sampleSize * FROM $tableName TABLESAMPLE ($samplePercent PERCENT) ORDER BY NEWID()) AS randomly_sampled"

airbyte-integrations/connectors/source-mssql/src/test/kotlin/io/airbyte/integrations/source/mssql/MsSqlServerSourceSelectQueryGeneratorTest.kt

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
4040
From("users", "dbo"),
4141
limit = Limit(0),
4242
)
43-
.assertSqlEquals("""SELECT TOP 0 id, name FROM dbo.users""")
43+
.assertSqlEquals("""SELECT TOP 0 [id], [name] FROM [dbo].[users]""")
4444
}
4545

4646
@Test
@@ -49,7 +49,7 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
4949
SelectColumnMaxValue(Field("updated_at", OffsetDateTimeFieldType)),
5050
From("orders", "dbo"),
5151
)
52-
.assertSqlEquals("""SELECT MAX(updated_at) FROM dbo.orders""")
52+
.assertSqlEquals("""SELECT MAX([updated_at]) FROM [dbo].[orders]""")
5353
}
5454

5555
@Test
@@ -63,7 +63,7 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
6363
),
6464
From("products", "dbo"),
6565
)
66-
.assertSqlEquals("""SELECT id, description FROM dbo.products""")
66+
.assertSqlEquals("""SELECT [id], [description] FROM [dbo].[products]""")
6767
}
6868

6969
@Test
@@ -90,11 +90,11 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
9090
Limit(1000),
9191
)
9292
.assertSqlEquals(
93-
"""SELECT TOP 1000 pk1, pk2, pk3, data FROM """ +
94-
"""dbo.composite_table WHERE (pk1 > ?) OR """ +
95-
"""((pk1 = ?) AND (pk2 > ?)) OR """ +
96-
"""((pk1 = ?) AND (pk2 = ?) AND (pk3 > ?)) """ +
97-
"""ORDER BY pk1, pk2, pk3""",
93+
"""SELECT TOP 1000 [pk1], [pk2], [pk3], [data] FROM """ +
94+
"""[dbo].[composite_table] WHERE ([pk1] > ?) OR """ +
95+
"""(([pk1] = ?) AND ([pk2] > ?)) OR """ +
96+
"""(([pk1] = ?) AND ([pk2] = ?) AND ([pk3] > ?)) """ +
97+
"""ORDER BY [pk1], [pk2], [pk3]""",
9898
v1 to IntFieldType,
9999
v1 to IntFieldType,
100100
v2 to IntFieldType,
@@ -117,9 +117,9 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
117117
Limit(500),
118118
)
119119
.assertSqlEquals(
120-
"""SELECT TOP 500 content, last_modified FROM """ +
121-
"""dbo.documents """ +
122-
"""WHERE (last_modified > ?) AND (last_modified <= ?) ORDER BY last_modified""",
120+
"""SELECT TOP 500 [content], [last_modified] FROM """ +
121+
"""[dbo].[documents] """ +
122+
"""WHERE ([last_modified] > ?) AND ([last_modified] <= ?) ORDER BY [last_modified]""",
123123
lb to DoubleFieldType,
124124
ub to DoubleFieldType,
125125
)
@@ -140,7 +140,7 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
140140
From("employees", "hr"),
141141
)
142142
.assertSqlEquals(
143-
"""SELECT employee_id, org_node.ToString(), employee_name FROM hr.employees"""
143+
"""SELECT [employee_id], [org_node].ToString(), [employee_name] FROM [hr].[employees]"""
144144
)
145145
}
146146

@@ -157,7 +157,7 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
157157
From("simple_table", null),
158158
limit = Limit(10),
159159
)
160-
.assertSqlEquals("""SELECT TOP 10 col1, col2 FROM simple_table""")
160+
.assertSqlEquals("""SELECT TOP 10 [col1], [col2] FROM [simple_table]""")
161161
}
162162

163163
@Test
@@ -173,7 +173,7 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
173173
Limit(10000),
174174
)
175175
.assertSqlEquals(
176-
"""SELECT TOP 10000 sequence_id, payload FROM dbo.events WHERE sequence_id > ? ORDER BY sequence_id""",
176+
"""SELECT TOP 10000 [sequence_id], [payload] FROM [dbo].[events] WHERE [sequence_id] > ? ORDER BY [sequence_id]""",
177177
startValue to LongFieldType,
178178
)
179179
}
@@ -201,13 +201,30 @@ class MsSqlServerSourceSelectQueryGeneratorTest {
201201
Limit(100),
202202
)
203203
.assertSqlEquals(
204-
"""SELECT TOP 100 id, created_at, updated_at FROM dbo.records """ +
205-
"""WHERE (created_at > ?) AND (updated_at <= ?) ORDER BY created_at, updated_at""",
204+
"""SELECT TOP 100 [id], [created_at], [updated_at] FROM [dbo].[records] """ +
205+
"""WHERE ([created_at] > ?) AND ([updated_at] <= ?) ORDER BY [created_at], [updated_at]""",
206206
createdAfter to OffsetDateTimeFieldType,
207207
updatedBefore to OffsetDateTimeFieldType,
208208
)
209209
}
210210

211+
@Test
212+
fun testSelectWithReservedKeywords() {
213+
// Test with reserved SQL Server keywords as column names (e.g., "End", "Start")
214+
val endField = Field("End", OffsetDateTimeFieldType)
215+
val startField = Field("Start", OffsetDateTimeFieldType)
216+
val orderField = Field("Order", IntFieldType)
217+
218+
SelectQuerySpec(
219+
SelectColumns(listOf(Field("Id", IntFieldType), startField, endField, orderField)),
220+
From("CustomerAgreementProfiles", "dbo"),
221+
limit = Limit(100),
222+
)
223+
.assertSqlEquals(
224+
"""SELECT TOP 100 [Id], [Start], [End], [Order] FROM [dbo].[CustomerAgreementProfiles]"""
225+
)
226+
}
227+
211228
private fun SelectQuerySpec.assertSqlEquals(
212229
sql: String,
213230
vararg bindings: Pair<JsonNode, LosslessJdbcFieldType<*, *>>,

docs/integrations/sources/mssql.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ WHERE actor_definition_id ='b5ea17b1-f170-46dc-bc31-cc744ca984c1' AND (configura
454454

455455
| Version | Date | Pull Request | Subject |
456456
|:-----------|:-----------|:------------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------|
457+
| 4.3.0-rc.5 | 2025-11-08 | [69248](https://github.com/airbytehq/airbyte/pull/69248) | Add SQL Server identifier quoting to fix reserved keyword issues |
457458
| 4.3.0-rc.4 | 2025-11-05 | [69194](https://github.com/airbytehq/airbyte/pull/69194) | Fix composite primary key discovery to show all PK columns; separate PK discovery from sync strategy |
458459
| 4.3.0-rc.3 | 2025-10-31 | [69097](https://github.com/airbytehq/airbyte/pull/69097) | Fix connector state value type from string to json object |
459460
| 4.3.0-rc.2 | 2025-10-29 | [69093](https://github.com/airbytehq/airbyte/pull/69093) | Fix state parsing error for integer and number |

0 commit comments

Comments
 (0)