sqlglot.schema
1from __future__ import annotations 2 3import abc 4import typing as t 5 6from sqlglot import expressions as exp 7from sqlglot.dialects.dialect import Dialect 8from sqlglot.errors import SchemaError 9from sqlglot.helper import dict_depth 10from sqlglot.trie import TrieResult, in_trie, new_trie 11 12if t.TYPE_CHECKING: 13 from sqlglot.dataframe.sql.types import StructType 14 from sqlglot.dialects.dialect import DialectType 15 16 ColumnMapping = t.Union[t.Dict, str, StructType, t.List] 17 18TABLE_ARGS = ("this", "db", "catalog") 19 20 21class Schema(abc.ABC): 22 """Abstract base class for database schemas""" 23 24 dialect: DialectType 25 26 @abc.abstractmethod 27 def add_table( 28 self, 29 table: exp.Table | str, 30 column_mapping: t.Optional[ColumnMapping] = None, 31 dialect: DialectType = None, 32 normalize: t.Optional[bool] = None, 33 match_depth: bool = True, 34 ) -> None: 35 """ 36 Register or update a table. Some implementing classes may require column information to also be provided. 37 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 38 39 Args: 40 table: the `Table` expression instance or string representing the table. 41 column_mapping: a column mapping that describes the structure of the table. 42 dialect: the SQL dialect that will be used to parse `table` if it's a string. 43 normalize: whether to normalize identifiers according to the dialect of interest. 44 match_depth: whether to enforce that the table must match the schema's depth or not. 45 """ 46 47 @abc.abstractmethod 48 def column_names( 49 self, 50 table: exp.Table | str, 51 only_visible: bool = False, 52 dialect: DialectType = None, 53 normalize: t.Optional[bool] = None, 54 ) -> t.List[str]: 55 """ 56 Get the column names for a table. 57 58 Args: 59 table: the `Table` expression instance. 60 only_visible: whether to include invisible columns. 61 dialect: the SQL dialect that will be used to parse `table` if it's a string. 62 normalize: whether to normalize identifiers according to the dialect of interest. 63 64 Returns: 65 The list of column names. 66 """ 67 68 @abc.abstractmethod 69 def get_column_type( 70 self, 71 table: exp.Table | str, 72 column: exp.Column | str, 73 dialect: DialectType = None, 74 normalize: t.Optional[bool] = None, 75 ) -> exp.DataType: 76 """ 77 Get the `sqlglot.exp.DataType` type of a column in the schema. 78 79 Args: 80 table: the source table. 81 column: the target column. 82 dialect: the SQL dialect that will be used to parse `table` if it's a string. 83 normalize: whether to normalize identifiers according to the dialect of interest. 84 85 Returns: 86 The resulting column type. 87 """ 88 89 def has_column( 90 self, 91 table: exp.Table | str, 92 column: exp.Column | str, 93 dialect: DialectType = None, 94 normalize: t.Optional[bool] = None, 95 ) -> bool: 96 """ 97 Returns whether or not `column` appears in `table`'s schema. 98 99 Args: 100 table: the source table. 101 column: the target column. 102 dialect: the SQL dialect that will be used to parse `table` if it's a string. 103 normalize: whether to normalize identifiers according to the dialect of interest. 104 105 Returns: 106 True if the column appears in the schema, False otherwise. 107 """ 108 name = column if isinstance(column, str) else column.name 109 return name in self.column_names(table, dialect=dialect, normalize=normalize) 110 111 @property 112 @abc.abstractmethod 113 def supported_table_args(self) -> t.Tuple[str, ...]: 114 """ 115 Table arguments this schema support, e.g. `("this", "db", "catalog")` 116 """ 117 118 @property 119 def empty(self) -> bool: 120 """Returns whether or not the schema is empty.""" 121 return True 122 123 124class AbstractMappingSchema: 125 def __init__( 126 self, 127 mapping: t.Optional[t.Dict] = None, 128 ) -> None: 129 self.mapping = mapping or {} 130 self.mapping_trie = new_trie( 131 tuple(reversed(t)) for t in flatten_schema(self.mapping, depth=self.depth()) 132 ) 133 self._supported_table_args: t.Tuple[str, ...] = tuple() 134 135 @property 136 def empty(self) -> bool: 137 return not self.mapping 138 139 def depth(self) -> int: 140 return dict_depth(self.mapping) 141 142 @property 143 def supported_table_args(self) -> t.Tuple[str, ...]: 144 if not self._supported_table_args and self.mapping: 145 depth = self.depth() 146 147 if not depth: # None 148 self._supported_table_args = tuple() 149 elif 1 <= depth <= 3: 150 self._supported_table_args = TABLE_ARGS[:depth] 151 else: 152 raise SchemaError(f"Invalid mapping shape. Depth: {depth}") 153 154 return self._supported_table_args 155 156 def table_parts(self, table: exp.Table) -> t.List[str]: 157 if isinstance(table.this, exp.ReadCSV): 158 return [table.this.name] 159 return [table.text(part) for part in TABLE_ARGS if table.text(part)] 160 161 def find( 162 self, table: exp.Table, trie: t.Optional[t.Dict] = None, raise_on_missing: bool = True 163 ) -> t.Optional[t.Any]: 164 parts = self.table_parts(table)[0 : len(self.supported_table_args)] 165 value, trie = in_trie(self.mapping_trie if trie is None else trie, parts) 166 167 if value == TrieResult.FAILED: 168 return None 169 170 if value == TrieResult.PREFIX: 171 possibilities = flatten_schema(trie, depth=dict_depth(trie) - 1) 172 173 if len(possibilities) == 1: 174 parts.extend(possibilities[0]) 175 else: 176 message = ", ".join(".".join(parts) for parts in possibilities) 177 if raise_on_missing: 178 raise SchemaError(f"Ambiguous mapping for {table}: {message}.") 179 return None 180 181 return self.nested_get(parts, raise_on_missing=raise_on_missing) 182 183 def nested_get( 184 self, parts: t.Sequence[str], d: t.Optional[t.Dict] = None, raise_on_missing=True 185 ) -> t.Optional[t.Any]: 186 return nested_get( 187 d or self.mapping, 188 *zip(self.supported_table_args, reversed(parts)), 189 raise_on_missing=raise_on_missing, 190 ) 191 192 193class MappingSchema(AbstractMappingSchema, Schema): 194 """ 195 Schema based on a nested mapping. 196 197 Args: 198 schema: Mapping in one of the following forms: 199 1. {table: {col: type}} 200 2. {db: {table: {col: type}}} 201 3. {catalog: {db: {table: {col: type}}}} 202 4. None - Tables will be added later 203 visible: Optional mapping of which columns in the schema are visible. If not provided, all columns 204 are assumed to be visible. The nesting should mirror that of the schema: 205 1. {table: set(*cols)}} 206 2. {db: {table: set(*cols)}}} 207 3. {catalog: {db: {table: set(*cols)}}}} 208 dialect: The dialect to be used for custom type mappings & parsing string arguments. 209 normalize: Whether to normalize identifier names according to the given dialect or not. 210 """ 211 212 def __init__( 213 self, 214 schema: t.Optional[t.Dict] = None, 215 visible: t.Optional[t.Dict] = None, 216 dialect: DialectType = None, 217 normalize: bool = True, 218 ) -> None: 219 self.dialect = dialect 220 self.visible = visible or {} 221 self.normalize = normalize 222 self._type_mapping_cache: t.Dict[str, exp.DataType] = {} 223 self._depth = 0 224 225 super().__init__(self._normalize(schema or {})) 226 227 @classmethod 228 def from_mapping_schema(cls, mapping_schema: MappingSchema) -> MappingSchema: 229 return MappingSchema( 230 schema=mapping_schema.mapping, 231 visible=mapping_schema.visible, 232 dialect=mapping_schema.dialect, 233 normalize=mapping_schema.normalize, 234 ) 235 236 def copy(self, **kwargs) -> MappingSchema: 237 return MappingSchema( 238 **{ # type: ignore 239 "schema": self.mapping.copy(), 240 "visible": self.visible.copy(), 241 "dialect": self.dialect, 242 "normalize": self.normalize, 243 **kwargs, 244 } 245 ) 246 247 def add_table( 248 self, 249 table: exp.Table | str, 250 column_mapping: t.Optional[ColumnMapping] = None, 251 dialect: DialectType = None, 252 normalize: t.Optional[bool] = None, 253 match_depth: bool = True, 254 ) -> None: 255 """ 256 Register or update a table. Updates are only performed if a new column mapping is provided. 257 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 258 259 Args: 260 table: the `Table` expression instance or string representing the table. 261 column_mapping: a column mapping that describes the structure of the table. 262 dialect: the SQL dialect that will be used to parse `table` if it's a string. 263 normalize: whether to normalize identifiers according to the dialect of interest. 264 match_depth: whether to enforce that the table must match the schema's depth or not. 265 """ 266 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 267 268 if match_depth and not self.empty and len(normalized_table.parts) != self.depth(): 269 raise SchemaError( 270 f"Table {normalized_table.sql(dialect=self.dialect)} must match the " 271 f"schema's nesting level: {self.depth()}." 272 ) 273 274 normalized_column_mapping = { 275 self._normalize_name(key, dialect=dialect, normalize=normalize): value 276 for key, value in ensure_column_mapping(column_mapping).items() 277 } 278 279 schema = self.find(normalized_table, raise_on_missing=False) 280 if schema and not normalized_column_mapping: 281 return 282 283 parts = self.table_parts(normalized_table) 284 285 nested_set(self.mapping, tuple(reversed(parts)), normalized_column_mapping) 286 new_trie([parts], self.mapping_trie) 287 288 def column_names( 289 self, 290 table: exp.Table | str, 291 only_visible: bool = False, 292 dialect: DialectType = None, 293 normalize: t.Optional[bool] = None, 294 ) -> t.List[str]: 295 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 296 297 schema = self.find(normalized_table) 298 if schema is None: 299 return [] 300 301 if not only_visible or not self.visible: 302 return list(schema) 303 304 visible = self.nested_get(self.table_parts(normalized_table), self.visible) or [] 305 return [col for col in schema if col in visible] 306 307 def get_column_type( 308 self, 309 table: exp.Table | str, 310 column: exp.Column | str, 311 dialect: DialectType = None, 312 normalize: t.Optional[bool] = None, 313 ) -> exp.DataType: 314 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 315 316 normalized_column_name = self._normalize_name( 317 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 318 ) 319 320 table_schema = self.find(normalized_table, raise_on_missing=False) 321 if table_schema: 322 column_type = table_schema.get(normalized_column_name) 323 324 if isinstance(column_type, exp.DataType): 325 return column_type 326 elif isinstance(column_type, str): 327 return self._to_data_type(column_type, dialect=dialect) 328 329 return exp.DataType.build("unknown") 330 331 def has_column( 332 self, 333 table: exp.Table | str, 334 column: exp.Column | str, 335 dialect: DialectType = None, 336 normalize: t.Optional[bool] = None, 337 ) -> bool: 338 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 339 340 normalized_column_name = self._normalize_name( 341 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 342 ) 343 344 table_schema = self.find(normalized_table, raise_on_missing=False) 345 return normalized_column_name in table_schema if table_schema else False 346 347 def _normalize(self, schema: t.Dict) -> t.Dict: 348 """ 349 Normalizes all identifiers in the schema. 350 351 Args: 352 schema: the schema to normalize. 353 354 Returns: 355 The normalized schema mapping. 356 """ 357 normalized_mapping: t.Dict = {} 358 flattened_schema = flatten_schema(schema, depth=dict_depth(schema) - 1) 359 360 for keys in flattened_schema: 361 columns = nested_get(schema, *zip(keys, keys)) 362 363 if not isinstance(columns, dict): 364 raise SchemaError( 365 f"Table {'.'.join(keys[:-1])} must match the schema's nesting level: {len(flattened_schema[0])}." 366 ) 367 368 normalized_keys = [self._normalize_name(key, is_table=True) for key in keys] 369 for column_name, column_type in columns.items(): 370 nested_set( 371 normalized_mapping, 372 normalized_keys + [self._normalize_name(column_name)], 373 column_type, 374 ) 375 376 return normalized_mapping 377 378 def _normalize_table( 379 self, 380 table: exp.Table | str, 381 dialect: DialectType = None, 382 normalize: t.Optional[bool] = None, 383 ) -> exp.Table: 384 dialect = dialect or self.dialect 385 normalize = self.normalize if normalize is None else normalize 386 387 normalized_table = exp.maybe_parse(table, into=exp.Table, dialect=dialect, copy=normalize) 388 389 if normalize: 390 for arg in TABLE_ARGS: 391 value = normalized_table.args.get(arg) 392 if isinstance(value, exp.Identifier): 393 normalized_table.set( 394 arg, 395 normalize_name(value, dialect=dialect, is_table=True, normalize=normalize), 396 ) 397 398 return normalized_table 399 400 def _normalize_name( 401 self, 402 name: str | exp.Identifier, 403 dialect: DialectType = None, 404 is_table: bool = False, 405 normalize: t.Optional[bool] = None, 406 ) -> str: 407 return normalize_name( 408 name, 409 dialect=dialect or self.dialect, 410 is_table=is_table, 411 normalize=self.normalize if normalize is None else normalize, 412 ).name 413 414 def depth(self) -> int: 415 if not self.empty and not self._depth: 416 # The columns themselves are a mapping, but we don't want to include those 417 self._depth = super().depth() - 1 418 return self._depth 419 420 def _to_data_type(self, schema_type: str, dialect: DialectType = None) -> exp.DataType: 421 """ 422 Convert a type represented as a string to the corresponding `sqlglot.exp.DataType` object. 423 424 Args: 425 schema_type: the type we want to convert. 426 dialect: the SQL dialect that will be used to parse `schema_type`, if needed. 427 428 Returns: 429 The resulting expression type. 430 """ 431 if schema_type not in self._type_mapping_cache: 432 dialect = dialect or self.dialect 433 udt = Dialect.get_or_raise(dialect).SUPPORTS_USER_DEFINED_TYPES 434 435 try: 436 expression = exp.DataType.build(schema_type, dialect=dialect, udt=udt) 437 self._type_mapping_cache[schema_type] = expression 438 except AttributeError: 439 in_dialect = f" in dialect {dialect}" if dialect else "" 440 raise SchemaError(f"Failed to build type '{schema_type}'{in_dialect}.") 441 442 return self._type_mapping_cache[schema_type] 443 444 445def normalize_name( 446 identifier: str | exp.Identifier, 447 dialect: DialectType = None, 448 is_table: bool = False, 449 normalize: t.Optional[bool] = True, 450) -> exp.Identifier: 451 if isinstance(identifier, str): 452 identifier = exp.parse_identifier(identifier, dialect=dialect) 453 454 if not normalize: 455 return identifier 456 457 # this is used for normalize_identifier, bigquery has special rules pertaining tables 458 identifier.meta["is_table"] = is_table 459 return Dialect.get_or_raise(dialect).normalize_identifier(identifier) 460 461 462def ensure_schema(schema: Schema | t.Optional[t.Dict], **kwargs: t.Any) -> Schema: 463 if isinstance(schema, Schema): 464 return schema 465 466 return MappingSchema(schema, **kwargs) 467 468 469def ensure_column_mapping(mapping: t.Optional[ColumnMapping]) -> t.Dict: 470 if mapping is None: 471 return {} 472 elif isinstance(mapping, dict): 473 return mapping 474 elif isinstance(mapping, str): 475 col_name_type_strs = [x.strip() for x in mapping.split(",")] 476 return { 477 name_type_str.split(":")[0].strip(): name_type_str.split(":")[1].strip() 478 for name_type_str in col_name_type_strs 479 } 480 # Check if mapping looks like a DataFrame StructType 481 elif hasattr(mapping, "simpleString"): 482 return {struct_field.name: struct_field.dataType.simpleString() for struct_field in mapping} 483 elif isinstance(mapping, list): 484 return {x.strip(): None for x in mapping} 485 486 raise ValueError(f"Invalid mapping provided: {type(mapping)}") 487 488 489def flatten_schema( 490 schema: t.Dict, depth: int, keys: t.Optional[t.List[str]] = None 491) -> t.List[t.List[str]]: 492 tables = [] 493 keys = keys or [] 494 495 for k, v in schema.items(): 496 if depth >= 2: 497 tables.extend(flatten_schema(v, depth - 1, keys + [k])) 498 elif depth == 1: 499 tables.append(keys + [k]) 500 501 return tables 502 503 504def nested_get( 505 d: t.Dict, *path: t.Tuple[str, str], raise_on_missing: bool = True 506) -> t.Optional[t.Any]: 507 """ 508 Get a value for a nested dictionary. 509 510 Args: 511 d: the dictionary to search. 512 *path: tuples of (name, key), where: 513 `key` is the key in the dictionary to get. 514 `name` is a string to use in the error if `key` isn't found. 515 516 Returns: 517 The value or None if it doesn't exist. 518 """ 519 for name, key in path: 520 d = d.get(key) # type: ignore 521 if d is None: 522 if raise_on_missing: 523 name = "table" if name == "this" else name 524 raise ValueError(f"Unknown {name}: {key}") 525 return None 526 527 return d 528 529 530def nested_set(d: t.Dict, keys: t.Sequence[str], value: t.Any) -> t.Dict: 531 """ 532 In-place set a value for a nested dictionary 533 534 Example: 535 >>> nested_set({}, ["top_key", "second_key"], "value") 536 {'top_key': {'second_key': 'value'}} 537 538 >>> nested_set({"top_key": {"third_key": "third_value"}}, ["top_key", "second_key"], "value") 539 {'top_key': {'third_key': 'third_value', 'second_key': 'value'}} 540 541 Args: 542 d: dictionary to update. 543 keys: the keys that makeup the path to `value`. 544 value: the value to set in the dictionary for the given key path. 545 546 Returns: 547 The (possibly) updated dictionary. 548 """ 549 if not keys: 550 return d 551 552 if len(keys) == 1: 553 d[keys[0]] = value 554 return d 555 556 subd = d 557 for key in keys[:-1]: 558 if key not in subd: 559 subd = subd.setdefault(key, {}) 560 else: 561 subd = subd[key] 562 563 subd[keys[-1]] = value 564 return d
22class Schema(abc.ABC): 23 """Abstract base class for database schemas""" 24 25 dialect: DialectType 26 27 @abc.abstractmethod 28 def add_table( 29 self, 30 table: exp.Table | str, 31 column_mapping: t.Optional[ColumnMapping] = None, 32 dialect: DialectType = None, 33 normalize: t.Optional[bool] = None, 34 match_depth: bool = True, 35 ) -> None: 36 """ 37 Register or update a table. Some implementing classes may require column information to also be provided. 38 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 39 40 Args: 41 table: the `Table` expression instance or string representing the table. 42 column_mapping: a column mapping that describes the structure of the table. 43 dialect: the SQL dialect that will be used to parse `table` if it's a string. 44 normalize: whether to normalize identifiers according to the dialect of interest. 45 match_depth: whether to enforce that the table must match the schema's depth or not. 46 """ 47 48 @abc.abstractmethod 49 def column_names( 50 self, 51 table: exp.Table | str, 52 only_visible: bool = False, 53 dialect: DialectType = None, 54 normalize: t.Optional[bool] = None, 55 ) -> t.List[str]: 56 """ 57 Get the column names for a table. 58 59 Args: 60 table: the `Table` expression instance. 61 only_visible: whether to include invisible columns. 62 dialect: the SQL dialect that will be used to parse `table` if it's a string. 63 normalize: whether to normalize identifiers according to the dialect of interest. 64 65 Returns: 66 The list of column names. 67 """ 68 69 @abc.abstractmethod 70 def get_column_type( 71 self, 72 table: exp.Table | str, 73 column: exp.Column | str, 74 dialect: DialectType = None, 75 normalize: t.Optional[bool] = None, 76 ) -> exp.DataType: 77 """ 78 Get the `sqlglot.exp.DataType` type of a column in the schema. 79 80 Args: 81 table: the source table. 82 column: the target column. 83 dialect: the SQL dialect that will be used to parse `table` if it's a string. 84 normalize: whether to normalize identifiers according to the dialect of interest. 85 86 Returns: 87 The resulting column type. 88 """ 89 90 def has_column( 91 self, 92 table: exp.Table | str, 93 column: exp.Column | str, 94 dialect: DialectType = None, 95 normalize: t.Optional[bool] = None, 96 ) -> bool: 97 """ 98 Returns whether or not `column` appears in `table`'s schema. 99 100 Args: 101 table: the source table. 102 column: the target column. 103 dialect: the SQL dialect that will be used to parse `table` if it's a string. 104 normalize: whether to normalize identifiers according to the dialect of interest. 105 106 Returns: 107 True if the column appears in the schema, False otherwise. 108 """ 109 name = column if isinstance(column, str) else column.name 110 return name in self.column_names(table, dialect=dialect, normalize=normalize) 111 112 @property 113 @abc.abstractmethod 114 def supported_table_args(self) -> t.Tuple[str, ...]: 115 """ 116 Table arguments this schema support, e.g. `("this", "db", "catalog")` 117 """ 118 119 @property 120 def empty(self) -> bool: 121 """Returns whether or not the schema is empty.""" 122 return True
Abstract base class for database schemas
27 @abc.abstractmethod 28 def add_table( 29 self, 30 table: exp.Table | str, 31 column_mapping: t.Optional[ColumnMapping] = None, 32 dialect: DialectType = None, 33 normalize: t.Optional[bool] = None, 34 match_depth: bool = True, 35 ) -> None: 36 """ 37 Register or update a table. Some implementing classes may require column information to also be provided. 38 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 39 40 Args: 41 table: the `Table` expression instance or string representing the table. 42 column_mapping: a column mapping that describes the structure of the table. 43 dialect: the SQL dialect that will be used to parse `table` if it's a string. 44 normalize: whether to normalize identifiers according to the dialect of interest. 45 match_depth: whether to enforce that the table must match the schema's depth or not. 46 """
Register or update a table. Some implementing classes may require column information to also be provided. The added table must have the necessary number of qualifiers in its path to match the schema's nesting level.
Arguments:
- table: the
Tableexpression instance or string representing the table. - column_mapping: a column mapping that describes the structure of the table.
- dialect: the SQL dialect that will be used to parse
tableif it's a string. - normalize: whether to normalize identifiers according to the dialect of interest.
- match_depth: whether to enforce that the table must match the schema's depth or not.
48 @abc.abstractmethod 49 def column_names( 50 self, 51 table: exp.Table | str, 52 only_visible: bool = False, 53 dialect: DialectType = None, 54 normalize: t.Optional[bool] = None, 55 ) -> t.List[str]: 56 """ 57 Get the column names for a table. 58 59 Args: 60 table: the `Table` expression instance. 61 only_visible: whether to include invisible columns. 62 dialect: the SQL dialect that will be used to parse `table` if it's a string. 63 normalize: whether to normalize identifiers according to the dialect of interest. 64 65 Returns: 66 The list of column names. 67 """
Get the column names for a table.
Arguments:
- table: the
Tableexpression instance. - only_visible: whether to include invisible columns.
- dialect: the SQL dialect that will be used to parse
tableif it's a string. - normalize: whether to normalize identifiers according to the dialect of interest.
Returns:
The list of column names.
69 @abc.abstractmethod 70 def get_column_type( 71 self, 72 table: exp.Table | str, 73 column: exp.Column | str, 74 dialect: DialectType = None, 75 normalize: t.Optional[bool] = None, 76 ) -> exp.DataType: 77 """ 78 Get the `sqlglot.exp.DataType` type of a column in the schema. 79 80 Args: 81 table: the source table. 82 column: the target column. 83 dialect: the SQL dialect that will be used to parse `table` if it's a string. 84 normalize: whether to normalize identifiers according to the dialect of interest. 85 86 Returns: 87 The resulting column type. 88 """
Get the sqlglot.exp.DataType type of a column in the schema.
Arguments:
- table: the source table.
- column: the target column.
- dialect: the SQL dialect that will be used to parse
tableif it's a string. - normalize: whether to normalize identifiers according to the dialect of interest.
Returns:
The resulting column type.
90 def has_column( 91 self, 92 table: exp.Table | str, 93 column: exp.Column | str, 94 dialect: DialectType = None, 95 normalize: t.Optional[bool] = None, 96 ) -> bool: 97 """ 98 Returns whether or not `column` appears in `table`'s schema. 99 100 Args: 101 table: the source table. 102 column: the target column. 103 dialect: the SQL dialect that will be used to parse `table` if it's a string. 104 normalize: whether to normalize identifiers according to the dialect of interest. 105 106 Returns: 107 True if the column appears in the schema, False otherwise. 108 """ 109 name = column if isinstance(column, str) else column.name 110 return name in self.column_names(table, dialect=dialect, normalize=normalize)
Returns whether or not column appears in table's schema.
Arguments:
- table: the source table.
- column: the target column.
- dialect: the SQL dialect that will be used to parse
tableif it's a string. - normalize: whether to normalize identifiers according to the dialect of interest.
Returns:
True if the column appears in the schema, False otherwise.
125class AbstractMappingSchema: 126 def __init__( 127 self, 128 mapping: t.Optional[t.Dict] = None, 129 ) -> None: 130 self.mapping = mapping or {} 131 self.mapping_trie = new_trie( 132 tuple(reversed(t)) for t in flatten_schema(self.mapping, depth=self.depth()) 133 ) 134 self._supported_table_args: t.Tuple[str, ...] = tuple() 135 136 @property 137 def empty(self) -> bool: 138 return not self.mapping 139 140 def depth(self) -> int: 141 return dict_depth(self.mapping) 142 143 @property 144 def supported_table_args(self) -> t.Tuple[str, ...]: 145 if not self._supported_table_args and self.mapping: 146 depth = self.depth() 147 148 if not depth: # None 149 self._supported_table_args = tuple() 150 elif 1 <= depth <= 3: 151 self._supported_table_args = TABLE_ARGS[:depth] 152 else: 153 raise SchemaError(f"Invalid mapping shape. Depth: {depth}") 154 155 return self._supported_table_args 156 157 def table_parts(self, table: exp.Table) -> t.List[str]: 158 if isinstance(table.this, exp.ReadCSV): 159 return [table.this.name] 160 return [table.text(part) for part in TABLE_ARGS if table.text(part)] 161 162 def find( 163 self, table: exp.Table, trie: t.Optional[t.Dict] = None, raise_on_missing: bool = True 164 ) -> t.Optional[t.Any]: 165 parts = self.table_parts(table)[0 : len(self.supported_table_args)] 166 value, trie = in_trie(self.mapping_trie if trie is None else trie, parts) 167 168 if value == TrieResult.FAILED: 169 return None 170 171 if value == TrieResult.PREFIX: 172 possibilities = flatten_schema(trie, depth=dict_depth(trie) - 1) 173 174 if len(possibilities) == 1: 175 parts.extend(possibilities[0]) 176 else: 177 message = ", ".join(".".join(parts) for parts in possibilities) 178 if raise_on_missing: 179 raise SchemaError(f"Ambiguous mapping for {table}: {message}.") 180 return None 181 182 return self.nested_get(parts, raise_on_missing=raise_on_missing) 183 184 def nested_get( 185 self, parts: t.Sequence[str], d: t.Optional[t.Dict] = None, raise_on_missing=True 186 ) -> t.Optional[t.Any]: 187 return nested_get( 188 d or self.mapping, 189 *zip(self.supported_table_args, reversed(parts)), 190 raise_on_missing=raise_on_missing, 191 )
162 def find( 163 self, table: exp.Table, trie: t.Optional[t.Dict] = None, raise_on_missing: bool = True 164 ) -> t.Optional[t.Any]: 165 parts = self.table_parts(table)[0 : len(self.supported_table_args)] 166 value, trie = in_trie(self.mapping_trie if trie is None else trie, parts) 167 168 if value == TrieResult.FAILED: 169 return None 170 171 if value == TrieResult.PREFIX: 172 possibilities = flatten_schema(trie, depth=dict_depth(trie) - 1) 173 174 if len(possibilities) == 1: 175 parts.extend(possibilities[0]) 176 else: 177 message = ", ".join(".".join(parts) for parts in possibilities) 178 if raise_on_missing: 179 raise SchemaError(f"Ambiguous mapping for {table}: {message}.") 180 return None 181 182 return self.nested_get(parts, raise_on_missing=raise_on_missing)
194class MappingSchema(AbstractMappingSchema, Schema): 195 """ 196 Schema based on a nested mapping. 197 198 Args: 199 schema: Mapping in one of the following forms: 200 1. {table: {col: type}} 201 2. {db: {table: {col: type}}} 202 3. {catalog: {db: {table: {col: type}}}} 203 4. None - Tables will be added later 204 visible: Optional mapping of which columns in the schema are visible. If not provided, all columns 205 are assumed to be visible. The nesting should mirror that of the schema: 206 1. {table: set(*cols)}} 207 2. {db: {table: set(*cols)}}} 208 3. {catalog: {db: {table: set(*cols)}}}} 209 dialect: The dialect to be used for custom type mappings & parsing string arguments. 210 normalize: Whether to normalize identifier names according to the given dialect or not. 211 """ 212 213 def __init__( 214 self, 215 schema: t.Optional[t.Dict] = None, 216 visible: t.Optional[t.Dict] = None, 217 dialect: DialectType = None, 218 normalize: bool = True, 219 ) -> None: 220 self.dialect = dialect 221 self.visible = visible or {} 222 self.normalize = normalize 223 self._type_mapping_cache: t.Dict[str, exp.DataType] = {} 224 self._depth = 0 225 226 super().__init__(self._normalize(schema or {})) 227 228 @classmethod 229 def from_mapping_schema(cls, mapping_schema: MappingSchema) -> MappingSchema: 230 return MappingSchema( 231 schema=mapping_schema.mapping, 232 visible=mapping_schema.visible, 233 dialect=mapping_schema.dialect, 234 normalize=mapping_schema.normalize, 235 ) 236 237 def copy(self, **kwargs) -> MappingSchema: 238 return MappingSchema( 239 **{ # type: ignore 240 "schema": self.mapping.copy(), 241 "visible": self.visible.copy(), 242 "dialect": self.dialect, 243 "normalize": self.normalize, 244 **kwargs, 245 } 246 ) 247 248 def add_table( 249 self, 250 table: exp.Table | str, 251 column_mapping: t.Optional[ColumnMapping] = None, 252 dialect: DialectType = None, 253 normalize: t.Optional[bool] = None, 254 match_depth: bool = True, 255 ) -> None: 256 """ 257 Register or update a table. Updates are only performed if a new column mapping is provided. 258 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 259 260 Args: 261 table: the `Table` expression instance or string representing the table. 262 column_mapping: a column mapping that describes the structure of the table. 263 dialect: the SQL dialect that will be used to parse `table` if it's a string. 264 normalize: whether to normalize identifiers according to the dialect of interest. 265 match_depth: whether to enforce that the table must match the schema's depth or not. 266 """ 267 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 268 269 if match_depth and not self.empty and len(normalized_table.parts) != self.depth(): 270 raise SchemaError( 271 f"Table {normalized_table.sql(dialect=self.dialect)} must match the " 272 f"schema's nesting level: {self.depth()}." 273 ) 274 275 normalized_column_mapping = { 276 self._normalize_name(key, dialect=dialect, normalize=normalize): value 277 for key, value in ensure_column_mapping(column_mapping).items() 278 } 279 280 schema = self.find(normalized_table, raise_on_missing=False) 281 if schema and not normalized_column_mapping: 282 return 283 284 parts = self.table_parts(normalized_table) 285 286 nested_set(self.mapping, tuple(reversed(parts)), normalized_column_mapping) 287 new_trie([parts], self.mapping_trie) 288 289 def column_names( 290 self, 291 table: exp.Table | str, 292 only_visible: bool = False, 293 dialect: DialectType = None, 294 normalize: t.Optional[bool] = None, 295 ) -> t.List[str]: 296 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 297 298 schema = self.find(normalized_table) 299 if schema is None: 300 return [] 301 302 if not only_visible or not self.visible: 303 return list(schema) 304 305 visible = self.nested_get(self.table_parts(normalized_table), self.visible) or [] 306 return [col for col in schema if col in visible] 307 308 def get_column_type( 309 self, 310 table: exp.Table | str, 311 column: exp.Column | str, 312 dialect: DialectType = None, 313 normalize: t.Optional[bool] = None, 314 ) -> exp.DataType: 315 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 316 317 normalized_column_name = self._normalize_name( 318 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 319 ) 320 321 table_schema = self.find(normalized_table, raise_on_missing=False) 322 if table_schema: 323 column_type = table_schema.get(normalized_column_name) 324 325 if isinstance(column_type, exp.DataType): 326 return column_type 327 elif isinstance(column_type, str): 328 return self._to_data_type(column_type, dialect=dialect) 329 330 return exp.DataType.build("unknown") 331 332 def has_column( 333 self, 334 table: exp.Table | str, 335 column: exp.Column | str, 336 dialect: DialectType = None, 337 normalize: t.Optional[bool] = None, 338 ) -> bool: 339 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 340 341 normalized_column_name = self._normalize_name( 342 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 343 ) 344 345 table_schema = self.find(normalized_table, raise_on_missing=False) 346 return normalized_column_name in table_schema if table_schema else False 347 348 def _normalize(self, schema: t.Dict) -> t.Dict: 349 """ 350 Normalizes all identifiers in the schema. 351 352 Args: 353 schema: the schema to normalize. 354 355 Returns: 356 The normalized schema mapping. 357 """ 358 normalized_mapping: t.Dict = {} 359 flattened_schema = flatten_schema(schema, depth=dict_depth(schema) - 1) 360 361 for keys in flattened_schema: 362 columns = nested_get(schema, *zip(keys, keys)) 363 364 if not isinstance(columns, dict): 365 raise SchemaError( 366 f"Table {'.'.join(keys[:-1])} must match the schema's nesting level: {len(flattened_schema[0])}." 367 ) 368 369 normalized_keys = [self._normalize_name(key, is_table=True) for key in keys] 370 for column_name, column_type in columns.items(): 371 nested_set( 372 normalized_mapping, 373 normalized_keys + [self._normalize_name(column_name)], 374 column_type, 375 ) 376 377 return normalized_mapping 378 379 def _normalize_table( 380 self, 381 table: exp.Table | str, 382 dialect: DialectType = None, 383 normalize: t.Optional[bool] = None, 384 ) -> exp.Table: 385 dialect = dialect or self.dialect 386 normalize = self.normalize if normalize is None else normalize 387 388 normalized_table = exp.maybe_parse(table, into=exp.Table, dialect=dialect, copy=normalize) 389 390 if normalize: 391 for arg in TABLE_ARGS: 392 value = normalized_table.args.get(arg) 393 if isinstance(value, exp.Identifier): 394 normalized_table.set( 395 arg, 396 normalize_name(value, dialect=dialect, is_table=True, normalize=normalize), 397 ) 398 399 return normalized_table 400 401 def _normalize_name( 402 self, 403 name: str | exp.Identifier, 404 dialect: DialectType = None, 405 is_table: bool = False, 406 normalize: t.Optional[bool] = None, 407 ) -> str: 408 return normalize_name( 409 name, 410 dialect=dialect or self.dialect, 411 is_table=is_table, 412 normalize=self.normalize if normalize is None else normalize, 413 ).name 414 415 def depth(self) -> int: 416 if not self.empty and not self._depth: 417 # The columns themselves are a mapping, but we don't want to include those 418 self._depth = super().depth() - 1 419 return self._depth 420 421 def _to_data_type(self, schema_type: str, dialect: DialectType = None) -> exp.DataType: 422 """ 423 Convert a type represented as a string to the corresponding `sqlglot.exp.DataType` object. 424 425 Args: 426 schema_type: the type we want to convert. 427 dialect: the SQL dialect that will be used to parse `schema_type`, if needed. 428 429 Returns: 430 The resulting expression type. 431 """ 432 if schema_type not in self._type_mapping_cache: 433 dialect = dialect or self.dialect 434 udt = Dialect.get_or_raise(dialect).SUPPORTS_USER_DEFINED_TYPES 435 436 try: 437 expression = exp.DataType.build(schema_type, dialect=dialect, udt=udt) 438 self._type_mapping_cache[schema_type] = expression 439 except AttributeError: 440 in_dialect = f" in dialect {dialect}" if dialect else "" 441 raise SchemaError(f"Failed to build type '{schema_type}'{in_dialect}.") 442 443 return self._type_mapping_cache[schema_type]
Schema based on a nested mapping.
Arguments:
- schema: Mapping in one of the following forms:
- {table: {col: type}}
- {db: {table: {col: type}}}
- {catalog: {db: {table: {col: type}}}}
- None - Tables will be added later
- visible: Optional mapping of which columns in the schema are visible. If not provided, all columns
are assumed to be visible. The nesting should mirror that of the schema:
- {table: set(cols)}}
- {db: {table: set(cols)}}}
- {catalog: {db: {table: set(*cols)}}}}
- dialect: The dialect to be used for custom type mappings & parsing string arguments.
- normalize: Whether to normalize identifier names according to the given dialect or not.
213 def __init__( 214 self, 215 schema: t.Optional[t.Dict] = None, 216 visible: t.Optional[t.Dict] = None, 217 dialect: DialectType = None, 218 normalize: bool = True, 219 ) -> None: 220 self.dialect = dialect 221 self.visible = visible or {} 222 self.normalize = normalize 223 self._type_mapping_cache: t.Dict[str, exp.DataType] = {} 224 self._depth = 0 225 226 super().__init__(self._normalize(schema or {}))
248 def add_table( 249 self, 250 table: exp.Table | str, 251 column_mapping: t.Optional[ColumnMapping] = None, 252 dialect: DialectType = None, 253 normalize: t.Optional[bool] = None, 254 match_depth: bool = True, 255 ) -> None: 256 """ 257 Register or update a table. Updates are only performed if a new column mapping is provided. 258 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 259 260 Args: 261 table: the `Table` expression instance or string representing the table. 262 column_mapping: a column mapping that describes the structure of the table. 263 dialect: the SQL dialect that will be used to parse `table` if it's a string. 264 normalize: whether to normalize identifiers according to the dialect of interest. 265 match_depth: whether to enforce that the table must match the schema's depth or not. 266 """ 267 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 268 269 if match_depth and not self.empty and len(normalized_table.parts) != self.depth(): 270 raise SchemaError( 271 f"Table {normalized_table.sql(dialect=self.dialect)} must match the " 272 f"schema's nesting level: {self.depth()}." 273 ) 274 275 normalized_column_mapping = { 276 self._normalize_name(key, dialect=dialect, normalize=normalize): value 277 for key, value in ensure_column_mapping(column_mapping).items() 278 } 279 280 schema = self.find(normalized_table, raise_on_missing=False) 281 if schema and not normalized_column_mapping: 282 return 283 284 parts = self.table_parts(normalized_table) 285 286 nested_set(self.mapping, tuple(reversed(parts)), normalized_column_mapping) 287 new_trie([parts], self.mapping_trie)
Register or update a table. Updates are only performed if a new column mapping is provided. The added table must have the necessary number of qualifiers in its path to match the schema's nesting level.
Arguments:
- table: the
Tableexpression instance or string representing the table. - column_mapping: a column mapping that describes the structure of the table.
- dialect: the SQL dialect that will be used to parse
tableif it's a string. - normalize: whether to normalize identifiers according to the dialect of interest.
- match_depth: whether to enforce that the table must match the schema's depth or not.
289 def column_names( 290 self, 291 table: exp.Table | str, 292 only_visible: bool = False, 293 dialect: DialectType = None, 294 normalize: t.Optional[bool] = None, 295 ) -> t.List[str]: 296 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 297 298 schema = self.find(normalized_table) 299 if schema is None: 300 return [] 301 302 if not only_visible or not self.visible: 303 return list(schema) 304 305 visible = self.nested_get(self.table_parts(normalized_table), self.visible) or [] 306 return [col for col in schema if col in visible]
Get the column names for a table.
Arguments:
- table: the
Tableexpression instance. - only_visible: whether to include invisible columns.
- dialect: the SQL dialect that will be used to parse
tableif it's a string. - normalize: whether to normalize identifiers according to the dialect of interest.
Returns:
The list of column names.
308 def get_column_type( 309 self, 310 table: exp.Table | str, 311 column: exp.Column | str, 312 dialect: DialectType = None, 313 normalize: t.Optional[bool] = None, 314 ) -> exp.DataType: 315 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 316 317 normalized_column_name = self._normalize_name( 318 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 319 ) 320 321 table_schema = self.find(normalized_table, raise_on_missing=False) 322 if table_schema: 323 column_type = table_schema.get(normalized_column_name) 324 325 if isinstance(column_type, exp.DataType): 326 return column_type 327 elif isinstance(column_type, str): 328 return self._to_data_type(column_type, dialect=dialect) 329 330 return exp.DataType.build("unknown")
Get the sqlglot.exp.DataType type of a column in the schema.
Arguments:
- table: the source table.
- column: the target column.
- dialect: the SQL dialect that will be used to parse
tableif it's a string. - normalize: whether to normalize identifiers according to the dialect of interest.
Returns:
The resulting column type.
332 def has_column( 333 self, 334 table: exp.Table | str, 335 column: exp.Column | str, 336 dialect: DialectType = None, 337 normalize: t.Optional[bool] = None, 338 ) -> bool: 339 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 340 341 normalized_column_name = self._normalize_name( 342 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 343 ) 344 345 table_schema = self.find(normalized_table, raise_on_missing=False) 346 return normalized_column_name in table_schema if table_schema else False
Returns whether or not column appears in table's schema.
Arguments:
- table: the source table.
- column: the target column.
- dialect: the SQL dialect that will be used to parse
tableif it's a string. - normalize: whether to normalize identifiers according to the dialect of interest.
Returns:
True if the column appears in the schema, False otherwise.
Inherited Members
446def normalize_name( 447 identifier: str | exp.Identifier, 448 dialect: DialectType = None, 449 is_table: bool = False, 450 normalize: t.Optional[bool] = True, 451) -> exp.Identifier: 452 if isinstance(identifier, str): 453 identifier = exp.parse_identifier(identifier, dialect=dialect) 454 455 if not normalize: 456 return identifier 457 458 # this is used for normalize_identifier, bigquery has special rules pertaining tables 459 identifier.meta["is_table"] = is_table 460 return Dialect.get_or_raise(dialect).normalize_identifier(identifier)
470def ensure_column_mapping(mapping: t.Optional[ColumnMapping]) -> t.Dict: 471 if mapping is None: 472 return {} 473 elif isinstance(mapping, dict): 474 return mapping 475 elif isinstance(mapping, str): 476 col_name_type_strs = [x.strip() for x in mapping.split(",")] 477 return { 478 name_type_str.split(":")[0].strip(): name_type_str.split(":")[1].strip() 479 for name_type_str in col_name_type_strs 480 } 481 # Check if mapping looks like a DataFrame StructType 482 elif hasattr(mapping, "simpleString"): 483 return {struct_field.name: struct_field.dataType.simpleString() for struct_field in mapping} 484 elif isinstance(mapping, list): 485 return {x.strip(): None for x in mapping} 486 487 raise ValueError(f"Invalid mapping provided: {type(mapping)}")
490def flatten_schema( 491 schema: t.Dict, depth: int, keys: t.Optional[t.List[str]] = None 492) -> t.List[t.List[str]]: 493 tables = [] 494 keys = keys or [] 495 496 for k, v in schema.items(): 497 if depth >= 2: 498 tables.extend(flatten_schema(v, depth - 1, keys + [k])) 499 elif depth == 1: 500 tables.append(keys + [k]) 501 502 return tables
505def nested_get( 506 d: t.Dict, *path: t.Tuple[str, str], raise_on_missing: bool = True 507) -> t.Optional[t.Any]: 508 """ 509 Get a value for a nested dictionary. 510 511 Args: 512 d: the dictionary to search. 513 *path: tuples of (name, key), where: 514 `key` is the key in the dictionary to get. 515 `name` is a string to use in the error if `key` isn't found. 516 517 Returns: 518 The value or None if it doesn't exist. 519 """ 520 for name, key in path: 521 d = d.get(key) # type: ignore 522 if d is None: 523 if raise_on_missing: 524 name = "table" if name == "this" else name 525 raise ValueError(f"Unknown {name}: {key}") 526 return None 527 528 return d
Get a value for a nested dictionary.
Arguments:
- d: the dictionary to search.
- *path: tuples of (name, key), where:
keyis the key in the dictionary to get.nameis a string to use in the error ifkeyisn't found.
Returns:
The value or None if it doesn't exist.
531def nested_set(d: t.Dict, keys: t.Sequence[str], value: t.Any) -> t.Dict: 532 """ 533 In-place set a value for a nested dictionary 534 535 Example: 536 >>> nested_set({}, ["top_key", "second_key"], "value") 537 {'top_key': {'second_key': 'value'}} 538 539 >>> nested_set({"top_key": {"third_key": "third_value"}}, ["top_key", "second_key"], "value") 540 {'top_key': {'third_key': 'third_value', 'second_key': 'value'}} 541 542 Args: 543 d: dictionary to update. 544 keys: the keys that makeup the path to `value`. 545 value: the value to set in the dictionary for the given key path. 546 547 Returns: 548 The (possibly) updated dictionary. 549 """ 550 if not keys: 551 return d 552 553 if len(keys) == 1: 554 d[keys[0]] = value 555 return d 556 557 subd = d 558 for key in keys[:-1]: 559 if key not in subd: 560 subd = subd.setdefault(key, {}) 561 else: 562 subd = subd[key] 563 564 subd[keys[-1]] = value 565 return d
In-place set a value for a nested dictionary
Example:
>>> nested_set({}, ["top_key", "second_key"], "value") {'top_key': {'second_key': 'value'}}>>> nested_set({"top_key": {"third_key": "third_value"}}, ["top_key", "second_key"], "value") {'top_key': {'third_key': 'third_value', 'second_key': 'value'}}
Arguments:
- d: dictionary to update.
- keys: the keys that makeup the path to
value. - value: the value to set in the dictionary for the given key path.
Returns:
The (possibly) updated dictionary.