1010from sqlmesh .core .dialect import to_schema
1111from sqlmesh .core .engine_adapter .mixins import (
1212 ClusteredByMixin ,
13+ GrantsFromInfoSchemaMixin ,
1314 RowDiffMixin ,
1415 TableAlterClusterByOperation ,
1516)
3940 from google .cloud .bigquery .table import Table as BigQueryTable
4041
4142 from sqlmesh .core ._typing import SchemaName , SessionProperties , TableName
42- from sqlmesh .core .engine_adapter ._typing import BigframeSession , DF , Query
43+ from sqlmesh .core .engine_adapter ._typing import BigframeSession , DCL , DF , GrantsConfig , Query
4344 from sqlmesh .core .engine_adapter .base import QueryOrDF
4445
4546
5455
5556
5657@set_catalog ()
57- class BigQueryEngineAdapter (ClusteredByMixin , RowDiffMixin ):
58+ class BigQueryEngineAdapter (ClusteredByMixin , RowDiffMixin , GrantsFromInfoSchemaMixin ):
5859 """
5960 BigQuery Engine Adapter using the `google-cloud-bigquery` library's DB API.
6061 """
@@ -64,6 +65,11 @@ class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin):
6465 SUPPORTS_TRANSACTIONS = False
6566 SUPPORTS_MATERIALIZED_VIEWS = True
6667 SUPPORTS_CLONING = True
68+ SUPPORTS_GRANTS = True
69+ CURRENT_USER_OR_ROLE_EXPRESSION : exp .Expression = exp .func ("session_user" )
70+ SUPPORTS_MULTIPLE_GRANT_PRINCIPALS = True
71+ USE_CATALOG_IN_GRANTS = True
72+ GRANT_INFORMATION_SCHEMA_TABLE_NAME = "OBJECT_PRIVILEGES"
6773 MAX_TABLE_COMMENT_LENGTH = 1024
6874 MAX_COLUMN_COMMENT_LENGTH = 1024
6975 SUPPORTS_QUERY_EXECUTION_TRACKING = True
@@ -1297,6 +1303,103 @@ def _session_id(self) -> t.Any:
12971303 def _session_id (self , value : t .Any ) -> None :
12981304 self ._connection_pool .set_attribute ("session_id" , value )
12991305
1306+ def _get_current_schema (self ) -> str :
1307+ raise NotImplementedError ("BigQuery does not support current schema" )
1308+
1309+ def _get_bq_dataset_location (self , project : str , dataset : str ) -> str :
1310+ return self ._db_call (self .client .get_dataset , dataset_ref = f"{ project } .{ dataset } " ).location
1311+
1312+ def _get_grant_expression (self , table : exp .Table ) -> exp .Expression :
1313+ if not table .db :
1314+ raise ValueError (
1315+ f"Table { table .sql (dialect = self .dialect )} does not have a schema (dataset)"
1316+ )
1317+ project = table .catalog or self .get_current_catalog ()
1318+ if not project :
1319+ raise ValueError (
1320+ f"Table { table .sql (dialect = self .dialect )} does not have a catalog (project)"
1321+ )
1322+
1323+ dataset = table .db
1324+ table_name = table .name
1325+ location = self ._get_bq_dataset_location (project , dataset )
1326+
1327+ # https://cloud.google.com/bigquery/docs/information-schema-object-privileges
1328+ # OBJECT_PRIVILEGES is a project-level INFORMATION_SCHEMA view with regional qualifier
1329+ object_privileges_table = exp .to_table (
1330+ f"`{ project } `.`region-{ location } `.INFORMATION_SCHEMA.{ self .GRANT_INFORMATION_SCHEMA_TABLE_NAME } " ,
1331+ dialect = self .dialect ,
1332+ )
1333+ return (
1334+ exp .select ("privilege_type" , "grantee" )
1335+ .from_ (object_privileges_table )
1336+ .where (
1337+ exp .and_ (
1338+ exp .column ("object_schema" ).eq (exp .Literal .string (dataset )),
1339+ exp .column ("object_name" ).eq (exp .Literal .string (table_name )),
1340+ # Filter out current_user
1341+ # BigQuery grantees format: "user:email" or "group:name"
1342+ exp .func ("split" , exp .column ("grantee" ), exp .Literal .string (":" ))[
1343+ exp .func ("OFFSET" , exp .Literal .number ("1" ))
1344+ ].neq (self .CURRENT_USER_OR_ROLE_EXPRESSION ),
1345+ )
1346+ )
1347+ )
1348+
1349+ @staticmethod
1350+ def _grant_object_kind (table_type : DataObjectType ) -> str :
1351+ if table_type == DataObjectType .VIEW :
1352+ return "VIEW"
1353+ return "TABLE"
1354+
1355+ def _dcl_grants_config_expr (
1356+ self ,
1357+ dcl_cmd : t .Type [DCL ],
1358+ table : exp .Table ,
1359+ grant_config : GrantsConfig ,
1360+ table_type : DataObjectType = DataObjectType .TABLE ,
1361+ ) -> t .List [exp .Expression ]:
1362+ expressions : t .List [exp .Expression ] = []
1363+ if not grant_config :
1364+ return expressions
1365+
1366+ # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-control-language
1367+
1368+ def normalize_principal (p : str ) -> str :
1369+ if ":" not in p :
1370+ raise ValueError (f"Principal '{ p } ' missing a prefix label" )
1371+
1372+ # allUsers and allAuthenticatedUsers special groups that are cas-sensitive and must start with "specialGroup:"
1373+ if p .endswith ("allUsers" ) or p .endswith ("allAuthenticatedUsers" ):
1374+ if not p .startswith ("specialGroup:" ):
1375+ raise ValueError (
1376+ f"Special group principal '{ p } ' must start with 'specialGroup:' prefix label"
1377+ )
1378+ return p
1379+
1380+ label , principal = p .split (":" , 1 )
1381+ # always lowercase principals
1382+ return f"{ label } :{ principal .lower ()} "
1383+
1384+ object_kind = self ._grant_object_kind (table_type )
1385+ for privilege , principals in grant_config .items ():
1386+ if not principals :
1387+ continue
1388+
1389+ noramlized_principals = [exp .Literal .string (normalize_principal (p )) for p in principals ]
1390+ args : t .Dict [str , t .Any ] = {
1391+ "privileges" : [exp .GrantPrivilege (this = exp .to_identifier (privilege , quoted = True ))],
1392+ "securable" : table .copy (),
1393+ "principals" : noramlized_principals ,
1394+ }
1395+
1396+ if object_kind :
1397+ args ["kind" ] = exp .Var (this = object_kind )
1398+
1399+ expressions .append (dcl_cmd (** args )) # type: ignore[arg-type]
1400+
1401+ return expressions
1402+
13001403
13011404class _ErrorCounter :
13021405 """
0 commit comments