1111from sqlmesh .core .engine_adapter .base import _get_data_object_cache_key
1212from sqlmesh .core .engine_adapter .mixins import (
1313 ClusteredByMixin ,
14+ GrantsFromInfoSchemaMixin ,
1415 RowDiffMixin ,
1516 TableAlterClusterByOperation ,
1617)
4041 from google .cloud .bigquery .table import Table as BigQueryTable
4142
4243 from sqlmesh .core ._typing import SchemaName , SessionProperties , TableName
43- from sqlmesh .core .engine_adapter ._typing import BigframeSession , DF , Query
44+ from sqlmesh .core .engine_adapter ._typing import BigframeSession , DCL , DF , GrantsConfig , Query
4445 from sqlmesh .core .engine_adapter .base import QueryOrDF
4546
4647
5556
5657
5758@set_catalog ()
58- class BigQueryEngineAdapter (ClusteredByMixin , RowDiffMixin ):
59+ class BigQueryEngineAdapter (ClusteredByMixin , RowDiffMixin , GrantsFromInfoSchemaMixin ):
5960 """
6061 BigQuery Engine Adapter using the `google-cloud-bigquery` library's DB API.
6162 """
@@ -65,6 +66,11 @@ class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin):
6566 SUPPORTS_TRANSACTIONS = False
6667 SUPPORTS_MATERIALIZED_VIEWS = True
6768 SUPPORTS_CLONING = True
69+ SUPPORTS_GRANTS = True
70+ CURRENT_USER_OR_ROLE_EXPRESSION : exp .Expression = exp .func ("session_user" )
71+ SUPPORTS_MULTIPLE_GRANT_PRINCIPALS = True
72+ USE_CATALOG_IN_GRANTS = True
73+ GRANT_INFORMATION_SCHEMA_TABLE_NAME = "OBJECT_PRIVILEGES"
6874 MAX_TABLE_COMMENT_LENGTH = 1024
6975 MAX_COLUMN_COMMENT_LENGTH = 1024
7076 SUPPORTS_QUERY_EXECUTION_TRACKING = True
@@ -1326,6 +1332,103 @@ def _session_id(self) -> t.Any:
13261332 def _session_id (self , value : t .Any ) -> None :
13271333 self ._connection_pool .set_attribute ("session_id" , value )
13281334
1335+ def _get_current_schema (self ) -> str :
1336+ raise NotImplementedError ("BigQuery does not support current schema" )
1337+
1338+ def _get_bq_dataset_location (self , project : str , dataset : str ) -> str :
1339+ return self ._db_call (self .client .get_dataset , dataset_ref = f"{ project } .{ dataset } " ).location
1340+
1341+ def _get_grant_expression (self , table : exp .Table ) -> exp .Expression :
1342+ if not table .db :
1343+ raise ValueError (
1344+ f"Table { table .sql (dialect = self .dialect )} does not have a schema (dataset)"
1345+ )
1346+ project = table .catalog or self .get_current_catalog ()
1347+ if not project :
1348+ raise ValueError (
1349+ f"Table { table .sql (dialect = self .dialect )} does not have a catalog (project)"
1350+ )
1351+
1352+ dataset = table .db
1353+ table_name = table .name
1354+ location = self ._get_bq_dataset_location (project , dataset )
1355+
1356+ # https://cloud.google.com/bigquery/docs/information-schema-object-privileges
1357+ # OBJECT_PRIVILEGES is a project-level INFORMATION_SCHEMA view with regional qualifier
1358+ object_privileges_table = exp .to_table (
1359+ f"`{ project } `.`region-{ location } `.INFORMATION_SCHEMA.{ self .GRANT_INFORMATION_SCHEMA_TABLE_NAME } " ,
1360+ dialect = self .dialect ,
1361+ )
1362+ return (
1363+ exp .select ("privilege_type" , "grantee" )
1364+ .from_ (object_privileges_table )
1365+ .where (
1366+ exp .and_ (
1367+ exp .column ("object_schema" ).eq (exp .Literal .string (dataset )),
1368+ exp .column ("object_name" ).eq (exp .Literal .string (table_name )),
1369+ # Filter out current_user
1370+ # BigQuery grantees format: "user:email" or "group:name"
1371+ exp .func ("split" , exp .column ("grantee" ), exp .Literal .string (":" ))[
1372+ exp .func ("OFFSET" , exp .Literal .number ("1" ))
1373+ ].neq (self .CURRENT_USER_OR_ROLE_EXPRESSION ),
1374+ )
1375+ )
1376+ )
1377+
1378+ @staticmethod
1379+ def _grant_object_kind (table_type : DataObjectType ) -> str :
1380+ if table_type == DataObjectType .VIEW :
1381+ return "VIEW"
1382+ return "TABLE"
1383+
1384+ def _dcl_grants_config_expr (
1385+ self ,
1386+ dcl_cmd : t .Type [DCL ],
1387+ table : exp .Table ,
1388+ grant_config : GrantsConfig ,
1389+ table_type : DataObjectType = DataObjectType .TABLE ,
1390+ ) -> t .List [exp .Expression ]:
1391+ expressions : t .List [exp .Expression ] = []
1392+ if not grant_config :
1393+ return expressions
1394+
1395+ # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-control-language
1396+
1397+ def normalize_principal (p : str ) -> str :
1398+ if ":" not in p :
1399+ raise ValueError (f"Principal '{ p } ' missing a prefix label" )
1400+
1401+ # allUsers and allAuthenticatedUsers special groups that are cas-sensitive and must start with "specialGroup:"
1402+ if p .endswith ("allUsers" ) or p .endswith ("allAuthenticatedUsers" ):
1403+ if not p .startswith ("specialGroup:" ):
1404+ raise ValueError (
1405+ f"Special group principal '{ p } ' must start with 'specialGroup:' prefix label"
1406+ )
1407+ return p
1408+
1409+ label , principal = p .split (":" , 1 )
1410+ # always lowercase principals
1411+ return f"{ label } :{ principal .lower ()} "
1412+
1413+ object_kind = self ._grant_object_kind (table_type )
1414+ for privilege , principals in grant_config .items ():
1415+ if not principals :
1416+ continue
1417+
1418+ noramlized_principals = [exp .Literal .string (normalize_principal (p )) for p in principals ]
1419+ args : t .Dict [str , t .Any ] = {
1420+ "privileges" : [exp .GrantPrivilege (this = exp .to_identifier (privilege , quoted = True ))],
1421+ "securable" : table .copy (),
1422+ "principals" : noramlized_principals ,
1423+ }
1424+
1425+ if object_kind :
1426+ args ["kind" ] = exp .Var (this = object_kind )
1427+
1428+ expressions .append (dcl_cmd (** args )) # type: ignore[arg-type]
1429+
1430+ return expressions
1431+
13291432
13301433class _ErrorCounter :
13311434 """
0 commit comments