Skip to content

Commit 2216a6e

Browse files
etonlelsOpenCode google-vertex/claude-opus-4-8@default
authored andcommitted
fix(bigquery): preserve domain-scoped project IDs [CLAUDE] (#7693)
Domain-scoped (legacy) BigQuery project IDs have the form `domain.com:project-id`, where the dots in the domain are part of the project identifier rather than path separators. When such a project appeared in a backtick-quoted table reference (e.g. `domain.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`), the parser split the whole name on dots, peeling `domain` off as the catalog and corrupting the project ID into `com:project-id` — which BigQuery rejects as an invalid project ID. Isolate the leading `domain.com:project-id` segment as the catalog before splitting the remaining dataset/table parts, so the project ID round-trips losslessly. Non-domain-scoped references are unaffected. Co-authored-by: OpenCode google-vertex/claude-opus-4-8@default <noreply@opencode.ai>
1 parent eb35b13 commit 2216a6e

2 files changed

Lines changed: 85 additions & 2 deletions

File tree

sqlglot/parsers/bigquery.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ def _build_to_hex(args: list) -> exp.Hex | exp.MD5:
140140
return exp.MD5(this=arg.this) if isinstance(arg, exp.MD5Digest) else exp.LowerHex(this=arg)
141141

142142

143+
_DOMAIN_DOT = "\0" # placeholder; cannot occur in a SQL identifier
144+
145+
146+
def _split_qualified_name(name: str, min_num_words: int) -> list[str | None]:
147+
# A dotted reference (e.g. `project.dataset.table`) is split into a fixed number of parts,
148+
# the first of which is the project. Domain-scoped (legacy) project IDs have the form
149+
# `domain.com:project-id`, where the dots belong to the domain, not the path - and a project
150+
# ID itself can't contain dots, so every such dot precedes the colon. Mask those, then let
151+
# `split_num_words` split and pad as usual, to avoid corrupting the project ID.
152+
# See: https://docs.cloud.google.com/artifact-registry/docs/docker/names#domain
153+
colon = name.find(":")
154+
if colon != -1 and "." in name[:colon]:
155+
name = name[:colon].replace(".", _DOMAIN_DOT) + name[colon:]
156+
return [
157+
p and p.replace(_DOMAIN_DOT, ".") for p in split_num_words(name, ".", min_num_words)
158+
]
159+
160+
return split_num_words(name, ".", min_num_words)
161+
162+
143163
MAKE_INTERVAL_KWARGS = ["year", "month", "day", "hour", "minute", "second"]
144164

145165

@@ -419,7 +439,7 @@ def _parse_table_parts(
419439
alias = table.this
420440
catalog, db, this_id, *rest = (
421441
exp.to_identifier(p, quoted=True)
422-
for p in split_num_words(".".join(p.name for p in table.parts), ".", 3)
442+
for p in _split_qualified_name(".".join(p.name for p in table.parts), 3)
423443
)
424444

425445
for part in (catalog, db, this_id):
@@ -478,7 +498,7 @@ def _parse_column(self) -> exp.Expr | None:
478498
if any("." in p.name for p in parts):
479499
catalog, db, table, this, *rest = (
480500
exp.to_identifier(p, quoted=True)
481-
for p in split_num_words(".".join(p.name for p in parts), ".", 4)
501+
for p in _split_qualified_name(".".join(p.name for p in parts), 4)
482502
)
483503

484504
if rest and this:

tests/dialects/test_bigquery.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,69 @@ def test_bigquery(self):
4242
self.assertEqual(table.db, "x-0")
4343
self.assertEqual(table.name, "_y")
4444

45+
# Domain-scoped (legacy) project IDs (`domain.com:project-id`) contain dots in
46+
# the domain that must not be treated as path separators, otherwise the domain
47+
# prefix is dropped and the project ID becomes invalid.
48+
table = self.parse_one(
49+
"`domain.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`", into=exp.Table
50+
)
51+
self.assertEqual(table.catalog, "domain.com:project-id")
52+
self.assertEqual(table.db, "region-us")
53+
self.assertEqual(table.name, "INFORMATION_SCHEMA.JOBS")
54+
55+
table = self.parse_one("`domain.com:project-id.mydataset.mytable`", into=exp.Table)
56+
self.assertEqual(table.catalog, "domain.com:project-id")
57+
self.assertEqual(table.db, "mydataset")
58+
self.assertEqual(table.name, "mytable")
59+
60+
# A domain-scoped project with no dataset/table must stay in the name position
61+
# (like any bare identifier), not be promoted to catalog — otherwise consumers
62+
# that read the identifier's name (e.g. macro interpolation) lose the project.
63+
table = self.parse_one("`domain.com:project-id`", into=exp.Table)
64+
self.assertEqual(table.name, "domain.com:project-id")
65+
self.assertIsNone(table.args.get("catalog"))
66+
self.assertIsNone(table.args.get("db"))
67+
68+
# A colon without a domain (no dot before it) is not a domain-scoped project,
69+
# so the reference splits on dots as usual.
70+
table = self.parse_one("`proj:weird.ds.tbl`", into=exp.Table)
71+
self.assertEqual(table.catalog, "proj:weird")
72+
self.assertEqual(table.db, "ds")
73+
self.assertEqual(table.name, "tbl")
74+
75+
# Domains with multiple dots (e.g. `a.b.com:`) are kept intact too.
76+
table = self.parse_one("`a.b.com:project-id.mydataset.mytable`", into=exp.Table)
77+
self.assertEqual(table.catalog, "a.b.com:project-id")
78+
self.assertEqual(table.db, "mydataset")
79+
self.assertEqual(table.name, "mytable")
80+
81+
table = self.parse_one(
82+
"`a.b.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`", into=exp.Table
83+
)
84+
self.assertEqual(table.catalog, "a.b.com:project-id")
85+
self.assertEqual(table.db, "region-us")
86+
self.assertEqual(table.name, "INFORMATION_SCHEMA.JOBS")
87+
88+
table = self.parse_one("`a.b.com:project-id`", into=exp.Table)
89+
self.assertEqual(table.name, "a.b.com:project-id")
90+
self.assertIsNone(table.args.get("catalog"))
91+
self.assertIsNone(table.args.get("db"))
92+
93+
self.validate_identity("SELECT * FROM `domain.com:project-id.mydataset.mytable`")
94+
self.validate_identity(
95+
"SELECT * FROM `domain.com:project-id.region-us.INFORMATION_SCHEMA`.JOBS",
96+
"SELECT * FROM `domain.com:project-id.region-us.INFORMATION_SCHEMA.JOBS` AS JOBS",
97+
)
98+
self.validate_identity(
99+
"SELECT * FROM `domain.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`",
100+
"SELECT * FROM `domain.com:project-id.region-us.INFORMATION_SCHEMA.JOBS` AS `domain.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`",
101+
)
102+
self.validate_identity("SELECT * FROM `a.b.com:project-id.mydataset.mytable`")
103+
self.validate_identity(
104+
"SELECT * FROM `a.b.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`",
105+
"SELECT * FROM `a.b.com:project-id.region-us.INFORMATION_SCHEMA.JOBS` AS `a.b.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`",
106+
)
107+
45108
self.validate_identity("SAFE.SOME_RANDOM_FUNC(a, b, c)").assert_is(exp.SafeFunc)
46109
self.validate_identity(
47110
"SAFE.SUBSTR('foo', 0, -2)",

0 commit comments

Comments
 (0)