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
@@ -1319,6 +1325,103 @@ def _session_id(self) -> t.Any:
13191325 def _session_id (self , value : t .Any ) -> None :
13201326 self ._connection_pool .set_attribute ("session_id" , value )
13211327
1328+ def _get_current_schema (self ) -> str :
1329+ raise NotImplementedError ("BigQuery does not support current schema" )
1330+
1331+ def _get_bq_dataset_location (self , project : str , dataset : str ) -> str :
1332+ return self ._db_call (self .client .get_dataset , dataset_ref = f"{ project } .{ dataset } " ).location
1333+
1334+ def _get_grant_expression (self , table : exp .Table ) -> exp .Expression :
1335+ if not table .db :
1336+ raise ValueError (
1337+ f"Table { table .sql (dialect = self .dialect )} does not have a schema (dataset)"
1338+ )
1339+ project = table .catalog or self .get_current_catalog ()
1340+ if not project :
1341+ raise ValueError (
1342+ f"Table { table .sql (dialect = self .dialect )} does not have a catalog (project)"
1343+ )
1344+
1345+ dataset = table .db
1346+ table_name = table .name
1347+ location = self ._get_bq_dataset_location (project , dataset )
1348+
1349+ # https://cloud.google.com/bigquery/docs/information-schema-object-privileges
1350+ # OBJECT_PRIVILEGES is a project-level INFORMATION_SCHEMA view with regional qualifier
1351+ object_privileges_table = exp .to_table (
1352+ f"`{ project } `.`region-{ location } `.INFORMATION_SCHEMA.{ self .GRANT_INFORMATION_SCHEMA_TABLE_NAME } " ,
1353+ dialect = self .dialect ,
1354+ )
1355+ return (
1356+ exp .select ("privilege_type" , "grantee" )
1357+ .from_ (object_privileges_table )
1358+ .where (
1359+ exp .and_ (
1360+ exp .column ("object_schema" ).eq (exp .Literal .string (dataset )),
1361+ exp .column ("object_name" ).eq (exp .Literal .string (table_name )),
1362+ # Filter out current_user
1363+ # BigQuery grantees format: "user:email" or "group:name"
1364+ exp .func ("split" , exp .column ("grantee" ), exp .Literal .string (":" ))[
1365+ exp .func ("OFFSET" , exp .Literal .number ("1" ))
1366+ ].neq (self .CURRENT_USER_OR_ROLE_EXPRESSION ),
1367+ )
1368+ )
1369+ )
1370+
1371+ @staticmethod
1372+ def _grant_object_kind (table_type : DataObjectType ) -> str :
1373+ if table_type == DataObjectType .VIEW :
1374+ return "VIEW"
1375+ return "TABLE"
1376+
1377+ def _dcl_grants_config_expr (
1378+ self ,
1379+ dcl_cmd : t .Type [DCL ],
1380+ table : exp .Table ,
1381+ grant_config : GrantsConfig ,
1382+ table_type : DataObjectType = DataObjectType .TABLE ,
1383+ ) -> t .List [exp .Expression ]:
1384+ expressions : t .List [exp .Expression ] = []
1385+ if not grant_config :
1386+ return expressions
1387+
1388+ # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-control-language
1389+
1390+ def normalize_principal (p : str ) -> str :
1391+ if ":" not in p :
1392+ raise ValueError (f"Principal '{ p } ' missing a prefix label" )
1393+
1394+ # allUsers and allAuthenticatedUsers special groups that are cas-sensitive and must start with "specialGroup:"
1395+ if p .endswith ("allUsers" ) or p .endswith ("allAuthenticatedUsers" ):
1396+ if not p .startswith ("specialGroup:" ):
1397+ raise ValueError (
1398+ f"Special group principal '{ p } ' must start with 'specialGroup:' prefix label"
1399+ )
1400+ return p
1401+
1402+ label , principal = p .split (":" , 1 )
1403+ # always lowercase principals
1404+ return f"{ label } :{ principal .lower ()} "
1405+
1406+ object_kind = self ._grant_object_kind (table_type )
1407+ for privilege , principals in grant_config .items ():
1408+ if not principals :
1409+ continue
1410+
1411+ noramlized_principals = [exp .Literal .string (normalize_principal (p )) for p in principals ]
1412+ args : t .Dict [str , t .Any ] = {
1413+ "privileges" : [exp .GrantPrivilege (this = exp .to_identifier (privilege , quoted = True ))],
1414+ "securable" : table .copy (),
1415+ "principals" : noramlized_principals ,
1416+ }
1417+
1418+ if object_kind :
1419+ args ["kind" ] = exp .Var (this = object_kind )
1420+
1421+ expressions .append (dcl_cmd (** args )) # type: ignore[arg-type]
1422+
1423+ return expressions
1424+
13221425
13231426class _ErrorCounter :
13241427 """
0 commit comments