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, first 10from sqlglot.trie import TrieResult, in_trie, new_trie 11 12 13if t.TYPE_CHECKING: 14 from sqlglot._typing import SchemaArgs 15 from sqlglot.dialects.dialect import DialectType 16 from collections.abc import Sequence 17 from typing_extensions import Unpack 18 19 ColumnMapping = t.Union[dict[str, t.Any], str, list[str]] 20 21 22class Schema(abc.ABC): 23 """Abstract base class for database schemas""" 24 25 @property 26 def dialect(self) -> Dialect | None: 27 """ 28 Returns None by default. Subclasses that require dialect-specific 29 behavior should override this property. 30 """ 31 return None 32 33 @abc.abstractmethod 34 def add_table( 35 self, 36 table: exp.Table | str, 37 column_mapping: ColumnMapping | None = None, 38 dialect: DialectType = None, 39 normalize: bool | None = None, 40 match_depth: bool = True, 41 ) -> None: 42 """ 43 Register or update a table. Some implementing classes may require column information to also be provided. 44 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 45 46 Args: 47 table: the `Table` expression instance or string representing the table. 48 column_mapping: a column mapping that describes the structure of the table. 49 dialect: the SQL dialect that will be used to parse `table` if it's a string. 50 normalize: whether to normalize identifiers according to the dialect of interest. 51 match_depth: whether to enforce that the table must match the schema's depth or not. 52 """ 53 54 @abc.abstractmethod 55 def column_names( 56 self, 57 table: exp.Table | str, 58 only_visible: bool = False, 59 dialect: DialectType = None, 60 normalize: bool | None = None, 61 ) -> Sequence[str]: 62 """ 63 Get the column names for a table. 64 65 Args: 66 table: the `Table` expression instance. 67 only_visible: whether to include invisible columns. 68 dialect: the SQL dialect that will be used to parse `table` if it's a string. 69 normalize: whether to normalize identifiers according to the dialect of interest. 70 71 Returns: 72 The sequence of column names. 73 """ 74 75 @abc.abstractmethod 76 def get_column_type( 77 self, 78 table: exp.Table | str, 79 column: exp.Column | str, 80 dialect: DialectType = None, 81 normalize: bool | None = None, 82 ) -> exp.DataType: 83 """ 84 Get the `sqlglot.exp.DataType` type of a column in the schema. 85 86 Args: 87 table: the source table. 88 column: the target column. 89 dialect: the SQL dialect that will be used to parse `table` if it's a string. 90 normalize: whether to normalize identifiers according to the dialect of interest. 91 92 Returns: 93 The resulting column type. 94 """ 95 96 def has_column( 97 self, 98 table: exp.Table | str, 99 column: exp.Column | str, 100 dialect: DialectType = None, 101 normalize: bool | None = None, 102 ) -> bool: 103 """ 104 Returns whether `column` appears in `table`'s schema. 105 106 Args: 107 table: the source table. 108 column: the target column. 109 dialect: the SQL dialect that will be used to parse `table` if it's a string. 110 normalize: whether to normalize identifiers according to the dialect of interest. 111 112 Returns: 113 True if the column appears in the schema, False otherwise. 114 """ 115 name = column if isinstance(column, str) else column.name 116 return name in self.column_names(table, dialect=dialect, normalize=normalize) 117 118 def get_udf_type( 119 self, 120 udf: exp.Anonymous | str, 121 dialect: DialectType = None, 122 normalize: bool | None = None, 123 ) -> exp.DataType: 124 """ 125 Get the return type of a UDF. 126 127 Args: 128 udf: the UDF expression or string. 129 dialect: the SQL dialect for parsing string arguments. 130 normalize: whether to normalize identifiers. 131 132 Returns: 133 The return type as a DataType, or UNKNOWN if not found. 134 """ 135 return exp.DType.UNKNOWN.into_expr() 136 137 @property 138 @abc.abstractmethod 139 def supported_table_args(self) -> tuple[str, ...]: 140 """ 141 Table arguments this schema support, e.g. `("this", "db", "catalog")` 142 """ 143 144 @property 145 def empty(self) -> bool: 146 """Returns whether the schema is empty.""" 147 return True 148 149 150class AbstractMappingSchema: 151 def __init__( 152 self, 153 mapping: dict[str, object] | None = None, 154 udf_mapping: dict[str, object] | None = None, 155 ) -> None: 156 self.mapping: dict[str, object] = mapping or {} 157 self.mapping_trie: dict[str, object] = new_trie( 158 tuple(reversed(t)) for t in flatten_schema(self.mapping, depth=self.depth()) 159 ) 160 self.udf_mapping: dict[str, object] = udf_mapping or {} 161 self.udf_trie: dict[str, object] = new_trie( 162 tuple(reversed(t)) for t in flatten_schema(self.udf_mapping, depth=self.udf_depth()) 163 ) 164 165 self._supported_table_args: tuple[str, ...] = tuple() 166 167 @property 168 def empty(self) -> bool: 169 return not self.mapping 170 171 def depth(self) -> int: 172 return dict_depth(self.mapping) 173 174 def udf_depth(self) -> int: 175 return dict_depth(self.udf_mapping) 176 177 @property 178 def supported_table_args(self) -> tuple[str, ...]: 179 if not self._supported_table_args and self.mapping: 180 depth = self.depth() 181 182 if not depth: # None 183 self._supported_table_args = tuple() 184 elif 1 <= depth <= 3: 185 self._supported_table_args = exp.TABLE_PARTS[:depth] 186 else: 187 raise SchemaError(f"Invalid mapping shape. Depth: {depth}") 188 189 return self._supported_table_args 190 191 def table_parts(self, table: exp.Table) -> list[str]: 192 return [p.name for p in reversed(table.parts)] 193 194 def udf_parts(self, udf: exp.Anonymous) -> list[str]: 195 # a.b.c(...) is represented as Dot(Dot(a, b), Anonymous(c, ...)) 196 parent = udf.parent 197 parts = [p.name for p in parent.flatten()] if isinstance(parent, exp.Dot) else [udf.name] 198 return list(reversed(parts))[0 : self.udf_depth()] 199 200 def _find_in_trie( 201 self, 202 parts: list[str], 203 trie: dict[str, object], 204 raise_on_missing: bool, 205 ) -> list[str] | None: 206 value, trie = in_trie(trie, parts) 207 208 if value == TrieResult.FAILED: 209 return None 210 211 if value == TrieResult.PREFIX: 212 possibilities = flatten_schema(trie) 213 214 if len(possibilities) == 1: 215 parts.extend(possibilities[0]) 216 else: 217 if raise_on_missing: 218 joined_parts = ".".join(parts) 219 message = ", ".join(".".join(p) for p in possibilities) 220 raise SchemaError(f"Ambiguous mapping for {joined_parts}: {message}.") 221 222 return None 223 224 return parts 225 226 def find( 227 self, table: exp.Table, raise_on_missing: bool = True, ensure_data_types: bool = False 228 ) -> t.Any | None: 229 """ 230 Returns the schema of a given table. 231 232 Args: 233 table: the target table. 234 raise_on_missing: whether to raise in case the schema is not found. 235 ensure_data_types: whether to convert `str` types to their `DataType` equivalents. 236 237 Returns: 238 The schema of the target table. 239 """ 240 parts = self.table_parts(table)[0 : len(self.supported_table_args)] 241 resolved_parts = self._find_in_trie(parts, self.mapping_trie, raise_on_missing) 242 243 if resolved_parts is None: 244 return None 245 246 return self.nested_get(resolved_parts, raise_on_missing=raise_on_missing) 247 248 def find_udf(self, udf: exp.Anonymous, raise_on_missing: bool = False) -> t.Any | None: 249 """ 250 Returns the return type of a given UDF. 251 252 Args: 253 udf: the target UDF expression. 254 raise_on_missing: whether to raise if the UDF is not found. 255 256 Returns: 257 The return type of the UDF, or None if not found. 258 """ 259 parts = self.udf_parts(udf) 260 resolved_parts = self._find_in_trie(parts, self.udf_trie, raise_on_missing) 261 262 if resolved_parts is None: 263 return None 264 265 return nested_get( 266 self.udf_mapping, 267 *zip(resolved_parts, reversed(resolved_parts)), 268 raise_on_missing=raise_on_missing, 269 ) 270 271 def nested_get( 272 self, 273 parts: Sequence[str], 274 d: dict[str, object] | None = None, 275 raise_on_missing: bool = True, 276 ) -> t.Any | None: 277 return nested_get( 278 d or self.mapping, 279 *zip(self.supported_table_args, reversed(parts)), 280 raise_on_missing=raise_on_missing, 281 ) 282 283 284class MappingSchema(AbstractMappingSchema, Schema): 285 """ 286 Schema based on a nested mapping. 287 288 Args: 289 schema: Mapping in one of the following forms: 290 1. {table: {col: type}} 291 2. {db: {table: {col: type}}} 292 3. {catalog: {db: {table: {col: type}}}} 293 4. None - Tables will be added later 294 visible: Optional mapping of which columns in the schema are visible. If not provided, all columns 295 are assumed to be visible. The nesting should mirror that of the schema: 296 1. {table: set(*cols)}} 297 2. {db: {table: set(*cols)}}} 298 3. {catalog: {db: {table: set(*cols)}}}} 299 dialect: The dialect to be used for custom type mappings & parsing string arguments. 300 normalize: Whether to normalize identifier names according to the given dialect or not. 301 """ 302 303 def __init__( 304 self, 305 schema: dict[str, object] | None = None, 306 visible: dict[str, object] | None = None, 307 dialect: DialectType = None, 308 normalize: bool = True, 309 udf_mapping: dict[str, object] | None = None, 310 ) -> None: 311 self.visible: dict[str, object] = {} if visible is None else visible 312 self.normalize: bool = normalize 313 self._dialect: Dialect = Dialect.get_or_raise(dialect) 314 self._type_mapping_cache: dict[str, exp.DataType] = {} 315 self._normalized_table_cache: dict[tuple[exp.Table, DialectType, bool], exp.Table] = {} 316 self._normalized_name_cache: dict[tuple[str, DialectType, bool, bool], str] = {} 317 self._find_cache: dict[tuple[exp.Table, bool], dict[str, object] | None] = {} 318 self._depth: int = 0 319 schema = {} if schema is None else schema 320 udf_mapping = {} if udf_mapping is None else udf_mapping 321 322 super().__init__( 323 self._normalize(schema) if self.normalize else schema, 324 self._normalize_udfs(udf_mapping) if self.normalize else udf_mapping, 325 ) 326 327 @property 328 def dialect(self) -> Dialect: 329 """Returns the dialect for this mapping schema.""" 330 return self._dialect 331 332 @classmethod 333 def from_mapping_schema(cls, mapping_schema: MappingSchema) -> MappingSchema: 334 return MappingSchema( 335 schema=mapping_schema.mapping, 336 visible=mapping_schema.visible, 337 dialect=mapping_schema.dialect, 338 normalize=mapping_schema.normalize, 339 udf_mapping=mapping_schema.udf_mapping, 340 ) 341 342 def find( 343 self, table: exp.Table, raise_on_missing: bool = True, ensure_data_types: bool = False 344 ) -> t.Any | None: 345 cache_key = (table, ensure_data_types) 346 schema = self._find_cache.get(cache_key) 347 348 if schema is None: 349 schema = super().find(table, raise_on_missing=raise_on_missing) 350 if ensure_data_types and isinstance(schema, dict): 351 schema = { 352 col: self._to_data_type(dtype) if isinstance(dtype, str) else dtype 353 for col, dtype in schema.items() 354 } 355 self._find_cache[cache_key] = schema 356 357 return schema 358 359 def copy( 360 self, schema: dict[str, object] | None = None, **kwargs: Unpack[SchemaArgs] 361 ) -> MappingSchema: 362 mapping_kwargs: SchemaArgs = { 363 "visible": self.visible.copy(), 364 "dialect": self.dialect, 365 "normalize": self.normalize, 366 "udf_mapping": self.udf_mapping.copy(), 367 **kwargs, 368 } 369 return MappingSchema(self.mapping.copy() if schema is None else schema, **mapping_kwargs) 370 371 def add_table( 372 self, 373 table: exp.Table | str, 374 column_mapping: ColumnMapping | None = None, 375 dialect: DialectType = None, 376 normalize: bool | None = None, 377 match_depth: bool = True, 378 ) -> None: 379 """ 380 Register or update a table. Updates are only performed if a new column mapping is provided. 381 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 382 383 Args: 384 table: the `Table` expression instance or string representing the table. 385 column_mapping: a column mapping that describes the structure of the table. 386 dialect: the SQL dialect that will be used to parse `table` if it's a string. 387 normalize: whether to normalize identifiers according to the dialect of interest. 388 match_depth: whether to enforce that the table must match the schema's depth or not. 389 """ 390 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 391 392 if match_depth and not self.empty and len(normalized_table.parts) != self.depth(): 393 raise SchemaError( 394 f"Table {normalized_table.sql(dialect=self.dialect)} must match the " 395 f"schema's nesting level: {self.depth()}." 396 ) 397 398 normalized_column_mapping = { 399 self._normalize_name(key, dialect=dialect, normalize=normalize): value 400 for key, value in ensure_column_mapping(column_mapping).items() 401 } 402 403 schema = self.find(normalized_table, raise_on_missing=False) 404 if schema and not normalized_column_mapping: 405 return 406 407 parts = self.table_parts(normalized_table) 408 409 nested_set(self.mapping, tuple(reversed(parts)), normalized_column_mapping) 410 new_trie([parts], self.mapping_trie) 411 self._find_cache.pop((normalized_table, True), None) 412 self._find_cache.pop((normalized_table, False), None) 413 414 def column_names( 415 self, 416 table: exp.Table | str, 417 only_visible: bool = False, 418 dialect: DialectType = None, 419 normalize: bool | None = None, 420 ) -> list[str]: 421 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 422 423 schema: dict[str, object] | None = self.find(normalized_table) 424 if schema is None: 425 return [] 426 427 if not only_visible or not self.visible: 428 return list(schema) 429 430 visible = self.nested_get(self.table_parts(normalized_table), self.visible) or [] 431 return [col for col in schema if col in visible] 432 433 def get_column_type( 434 self, 435 table: exp.Table | str, 436 column: exp.Column | str, 437 dialect: DialectType = None, 438 normalize: bool | None = None, 439 ) -> exp.DataType: 440 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 441 442 normalized_column_name = self._normalize_name( 443 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 444 ) 445 446 table_schema: dict[str, object] | None = self.find(normalized_table, raise_on_missing=False) 447 if table_schema: 448 column_type = table_schema.get(normalized_column_name) 449 450 if isinstance(column_type, exp.DataType): 451 return column_type 452 elif isinstance(column_type, str): 453 return self._to_data_type(column_type, dialect=dialect) 454 455 return exp.DType.UNKNOWN.into_expr() 456 457 def get_udf_type( 458 self, 459 udf: exp.Anonymous | str, 460 dialect: DialectType = None, 461 normalize: bool | None = None, 462 ) -> exp.DataType: 463 """ 464 Get the return type of a UDF. 465 466 Args: 467 udf: the UDF expression or string (e.g., "db.my_func()"). 468 dialect: the SQL dialect for parsing string arguments. 469 normalize: whether to normalize identifiers. 470 471 Returns: 472 The return type as a DataType, or UNKNOWN if not found. 473 """ 474 parts = self._normalize_udf(udf, dialect=dialect, normalize=normalize) 475 resolved_parts = self._find_in_trie(parts, self.udf_trie, raise_on_missing=False) 476 477 if resolved_parts is None: 478 return exp.DType.UNKNOWN.into_expr() 479 480 udf_type = nested_get( 481 self.udf_mapping, 482 *zip(resolved_parts, reversed(resolved_parts)), 483 raise_on_missing=False, 484 ) 485 486 if isinstance(udf_type, exp.DataType): 487 return udf_type 488 elif isinstance(udf_type, str): 489 return self._to_data_type(udf_type, dialect=dialect) 490 491 return exp.DType.UNKNOWN.into_expr() 492 493 def has_column( 494 self, 495 table: exp.Table | str, 496 column: exp.Column | str, 497 dialect: DialectType = None, 498 normalize: bool | None = None, 499 ) -> bool: 500 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 501 502 normalized_column_name = self._normalize_name( 503 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 504 ) 505 506 table_schema: dict[str, object] | None = self.find(normalized_table, raise_on_missing=False) 507 return normalized_column_name in table_schema if table_schema else False 508 509 def _normalize(self, schema: dict[str, object]) -> dict[str, object]: 510 """ 511 Normalizes all identifiers in the schema. 512 513 Args: 514 schema: the schema to normalize. 515 516 Returns: 517 The normalized schema mapping. 518 """ 519 normalized_mapping: dict[str, object] = {} 520 flattened_schema = flatten_schema(schema) 521 error_msg = "Table {} must match the schema's nesting level: {}." 522 523 for keys in flattened_schema: 524 columns = nested_get(schema, *zip(keys, keys)) 525 526 if not isinstance(columns, dict): 527 raise SchemaError(error_msg.format(".".join(keys[:-1]), len(flattened_schema[0]))) 528 if not columns: 529 raise SchemaError(f"Table {'.'.join(keys[:-1])} must have at least one column") 530 if isinstance(first(columns.values()), dict): 531 raise SchemaError( 532 error_msg.format( 533 ".".join(keys + flatten_schema(columns)[0]), len(flattened_schema[0]) 534 ), 535 ) 536 537 normalized_keys = [self._normalize_name(key, is_table=True) for key in keys] 538 for column_name, column_type in columns.items(): 539 nested_set( 540 normalized_mapping, 541 normalized_keys + [self._normalize_name(column_name)], 542 column_type, 543 ) 544 545 return normalized_mapping 546 547 def _normalize_udfs(self, udfs: dict[str, object]) -> dict[str, object]: 548 """ 549 Normalizes all identifiers in the UDF mapping. 550 551 Args: 552 udfs: the UDF mapping to normalize. 553 554 Returns: 555 The normalized UDF mapping. 556 """ 557 normalized_mapping: dict[str, object] = {} 558 559 for keys in flatten_schema(udfs, depth=dict_depth(udfs)): 560 udf_type = nested_get(udfs, *zip(keys, keys)) 561 normalized_keys = [self._normalize_name(key, is_table=True) for key in keys] 562 nested_set(normalized_mapping, normalized_keys, udf_type) 563 564 return normalized_mapping 565 566 def _normalize_udf( 567 self, 568 udf: exp.Anonymous | str, 569 dialect: DialectType = None, 570 normalize: bool | None = None, 571 ) -> list[str]: 572 """ 573 Extract and normalize UDF parts for lookup. 574 575 Args: 576 udf: the UDF expression or qualified string (e.g., "db.my_func()"). 577 dialect: the SQL dialect for parsing. 578 normalize: whether to normalize identifiers. 579 580 Returns: 581 A list of normalized UDF parts (reversed for trie lookup). 582 """ 583 dialect = dialect or self.dialect 584 normalize = self.normalize if normalize is None else normalize 585 586 if isinstance(udf, str): 587 parsed: exp.Expr = exp.maybe_parse(udf, dialect=dialect) 588 589 if isinstance(parsed, exp.Anonymous): 590 udf = parsed 591 elif isinstance(parsed, exp.Dot) and isinstance(parsed.expression, exp.Anonymous): 592 udf = parsed.expression 593 else: 594 raise SchemaError(f"Unable to parse UDF from: {udf!r}") 595 parts = self.udf_parts(udf) 596 597 if normalize: 598 parts = [self._normalize_name(part, dialect=dialect, is_table=True) for part in parts] 599 600 return parts 601 602 def _normalize_table( 603 self, 604 table: exp.Table | str, 605 dialect: DialectType = None, 606 normalize: bool | None = None, 607 ) -> exp.Table: 608 dialect = dialect or self.dialect 609 normalize = self.normalize if normalize is None else normalize 610 611 # Cache normalized tables by object id for exp.Table inputs 612 # This is effective when the same Table object is looked up multiple times 613 if isinstance(table, exp.Table) and ( 614 cached := self._normalized_table_cache.get((table, dialect, normalize)) 615 ): 616 return cached 617 618 normalized_table = exp.maybe_parse(table, into=exp.Table, dialect=dialect, copy=normalize) 619 620 if normalize: 621 for part in normalized_table.parts: 622 if isinstance(part, exp.Identifier): 623 part.replace( 624 normalize_name(part, dialect=dialect, is_table=True, normalize=normalize) 625 ) 626 627 self._normalized_table_cache[(normalized_table, dialect, normalize)] = normalized_table 628 return normalized_table 629 630 def _normalize_name( 631 self, 632 name: str | exp.Identifier, 633 dialect: DialectType = None, 634 is_table: bool = False, 635 normalize: bool | None = None, 636 ) -> str: 637 normalize = self.normalize if normalize is None else normalize 638 639 dialect = dialect or self.dialect 640 name_str = name if isinstance(name, str) else name.name 641 cache_key = (name_str, dialect, is_table, normalize) 642 643 if cached := self._normalized_name_cache.get(cache_key): 644 return cached 645 646 result = normalize_name( 647 name, 648 dialect=dialect, 649 is_table=is_table, 650 normalize=normalize, 651 ).name 652 653 self._normalized_name_cache[cache_key] = result 654 return result 655 656 def depth(self) -> int: 657 if not self.empty and not self._depth: 658 # The columns themselves are a mapping, but we don't want to include those 659 self._depth = super().depth() - 1 660 return self._depth 661 662 def _to_data_type(self, schema_type: str, dialect: DialectType = None) -> exp.DataType: 663 """ 664 Convert a type represented as a string to the corresponding `sqlglot.exp.DataType` object. 665 666 Args: 667 schema_type: the type we want to convert. 668 dialect: the SQL dialect that will be used to parse `schema_type`, if needed. 669 670 Returns: 671 The resulting expression type. 672 """ 673 if schema_type not in self._type_mapping_cache: 674 dialect = Dialect.get_or_raise(dialect) if dialect else self.dialect 675 udt = dialect.SUPPORTS_USER_DEFINED_TYPES 676 677 try: 678 expression = exp.DataType.from_str(schema_type, dialect=dialect, udt=udt) 679 expression.transform(dialect.normalize_identifier, copy=False) 680 self._type_mapping_cache[schema_type] = expression 681 except AttributeError: 682 in_dialect = f" in dialect {dialect}" if dialect else "" 683 raise SchemaError(f"Failed to build type '{schema_type}'{in_dialect}.") 684 685 return self._type_mapping_cache[schema_type] 686 687 688def normalize_name( 689 identifier: str | exp.Identifier, 690 dialect: DialectType = None, 691 is_table: bool = False, 692 normalize: bool | None = True, 693) -> exp.Identifier: 694 if isinstance(identifier, str): 695 identifier = exp.parse_identifier(identifier, dialect=dialect) 696 elif normalize: 697 identifier = identifier.copy() 698 699 if not normalize: 700 return identifier 701 702 # this is used for normalize_identifier, bigquery has special rules pertaining tables 703 identifier.meta["is_table"] = is_table 704 return Dialect.get_or_raise(dialect).normalize_identifier(identifier) 705 706 707def ensure_schema( 708 schema: Schema | dict[str, object] | None, **kwargs: Unpack[SchemaArgs] 709) -> Schema: 710 if isinstance(schema, Schema): 711 return schema 712 713 return MappingSchema(schema, **kwargs) 714 715 716def ensure_column_mapping(mapping: ColumnMapping | None) -> dict[str, t.Any]: 717 if mapping is None: 718 return {} 719 elif isinstance(mapping, dict): 720 return mapping 721 elif isinstance(mapping, str): 722 col_name_type_strs = [x.strip() for x in mapping.split(",")] 723 return { 724 name_type_str.split(":")[0].strip(): name_type_str.split(":")[1].strip() 725 for name_type_str in col_name_type_strs 726 } 727 elif isinstance(mapping, list): 728 return {x.strip(): None for x in mapping} 729 730 raise ValueError(f"Invalid mapping provided: {type(mapping)}") 731 732 733def flatten_schema( 734 schema: dict[str, object], depth: int | None = None, keys: list[str] | None = None 735) -> list[list[str]]: 736 tables: list[list[str]] = [] 737 keys = keys or [] 738 depth = dict_depth(schema) - 1 if depth is None else depth 739 740 for k, v in schema.items(): 741 if depth == 1 or not isinstance(v, dict): 742 tables.append(keys + [k]) 743 elif depth >= 2: 744 tables.extend(flatten_schema(v, depth - 1, keys + [k])) 745 746 return tables 747 748 749def nested_get( 750 d: dict[str, object], *path: tuple[str, str], raise_on_missing: bool = True 751) -> t.Any | None: 752 """ 753 Get a value for a nested dictionary. 754 755 Args: 756 d: the dictionary to search. 757 *path: tuples of (name, key), where: 758 `key` is the key in the dictionary to get. 759 `name` is a string to use in the error if `key` isn't found. 760 761 Returns: 762 The value or None if it doesn't exist. 763 """ 764 result: t.Any = d 765 for name, key in path: 766 result = result.get(key) 767 if result is None: 768 if raise_on_missing: 769 name = "table" if name == "this" else name 770 raise ValueError(f"Unknown {name}: {key}") 771 return None 772 773 return result 774 775 776def nested_set(d: dict[str, t.Any], keys: Sequence[str], value: t.Any) -> dict[str, t.Any]: 777 """ 778 In-place set a value for a nested dictionary 779 780 Example: 781 >>> nested_set({}, ["top_key", "second_key"], "value") 782 {'top_key': {'second_key': 'value'}} 783 784 >>> nested_set({"top_key": {"third_key": "third_value"}}, ["top_key", "second_key"], "value") 785 {'top_key': {'third_key': 'third_value', 'second_key': 'value'}} 786 787 Args: 788 d: dictionary to update. 789 keys: the keys that makeup the path to `value`. 790 value: the value to set in the dictionary for the given key path. 791 792 Returns: 793 The (possibly) updated dictionary. 794 """ 795 if not keys: 796 return d 797 798 if len(keys) == 1: 799 d[keys[0]] = value 800 return d 801 802 subd = d 803 for key in keys[:-1]: 804 if key not in subd: 805 subd = subd.setdefault(key, {}) 806 else: 807 subd = subd[key] 808 809 subd[keys[-1]] = value 810 return d
23class Schema(abc.ABC): 24 """Abstract base class for database schemas""" 25 26 @property 27 def dialect(self) -> Dialect | None: 28 """ 29 Returns None by default. Subclasses that require dialect-specific 30 behavior should override this property. 31 """ 32 return None 33 34 @abc.abstractmethod 35 def add_table( 36 self, 37 table: exp.Table | str, 38 column_mapping: ColumnMapping | None = None, 39 dialect: DialectType = None, 40 normalize: bool | None = None, 41 match_depth: bool = True, 42 ) -> None: 43 """ 44 Register or update a table. Some implementing classes may require column information to also be provided. 45 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 46 47 Args: 48 table: the `Table` expression instance or string representing the table. 49 column_mapping: a column mapping that describes the structure of the table. 50 dialect: the SQL dialect that will be used to parse `table` if it's a string. 51 normalize: whether to normalize identifiers according to the dialect of interest. 52 match_depth: whether to enforce that the table must match the schema's depth or not. 53 """ 54 55 @abc.abstractmethod 56 def column_names( 57 self, 58 table: exp.Table | str, 59 only_visible: bool = False, 60 dialect: DialectType = None, 61 normalize: bool | None = None, 62 ) -> Sequence[str]: 63 """ 64 Get the column names for a table. 65 66 Args: 67 table: the `Table` expression instance. 68 only_visible: whether to include invisible columns. 69 dialect: the SQL dialect that will be used to parse `table` if it's a string. 70 normalize: whether to normalize identifiers according to the dialect of interest. 71 72 Returns: 73 The sequence of column names. 74 """ 75 76 @abc.abstractmethod 77 def get_column_type( 78 self, 79 table: exp.Table | str, 80 column: exp.Column | str, 81 dialect: DialectType = None, 82 normalize: bool | None = None, 83 ) -> exp.DataType: 84 """ 85 Get the `sqlglot.exp.DataType` type of a column in the schema. 86 87 Args: 88 table: the source table. 89 column: the target column. 90 dialect: the SQL dialect that will be used to parse `table` if it's a string. 91 normalize: whether to normalize identifiers according to the dialect of interest. 92 93 Returns: 94 The resulting column type. 95 """ 96 97 def has_column( 98 self, 99 table: exp.Table | str, 100 column: exp.Column | str, 101 dialect: DialectType = None, 102 normalize: bool | None = None, 103 ) -> bool: 104 """ 105 Returns whether `column` appears in `table`'s schema. 106 107 Args: 108 table: the source table. 109 column: the target column. 110 dialect: the SQL dialect that will be used to parse `table` if it's a string. 111 normalize: whether to normalize identifiers according to the dialect of interest. 112 113 Returns: 114 True if the column appears in the schema, False otherwise. 115 """ 116 name = column if isinstance(column, str) else column.name 117 return name in self.column_names(table, dialect=dialect, normalize=normalize) 118 119 def get_udf_type( 120 self, 121 udf: exp.Anonymous | str, 122 dialect: DialectType = None, 123 normalize: bool | None = None, 124 ) -> exp.DataType: 125 """ 126 Get the return type of a UDF. 127 128 Args: 129 udf: the UDF expression or string. 130 dialect: the SQL dialect for parsing string arguments. 131 normalize: whether to normalize identifiers. 132 133 Returns: 134 The return type as a DataType, or UNKNOWN if not found. 135 """ 136 return exp.DType.UNKNOWN.into_expr() 137 138 @property 139 @abc.abstractmethod 140 def supported_table_args(self) -> tuple[str, ...]: 141 """ 142 Table arguments this schema support, e.g. `("this", "db", "catalog")` 143 """ 144 145 @property 146 def empty(self) -> bool: 147 """Returns whether the schema is empty.""" 148 return True
Abstract base class for database schemas
26 @property 27 def dialect(self) -> Dialect | None: 28 """ 29 Returns None by default. Subclasses that require dialect-specific 30 behavior should override this property. 31 """ 32 return None
Returns None by default. Subclasses that require dialect-specific behavior should override this property.
34 @abc.abstractmethod 35 def add_table( 36 self, 37 table: exp.Table | str, 38 column_mapping: ColumnMapping | None = None, 39 dialect: DialectType = None, 40 normalize: bool | None = None, 41 match_depth: bool = True, 42 ) -> None: 43 """ 44 Register or update a table. Some implementing classes may require column information to also be provided. 45 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 46 47 Args: 48 table: the `Table` expression instance or string representing the table. 49 column_mapping: a column mapping that describes the structure of the table. 50 dialect: the SQL dialect that will be used to parse `table` if it's a string. 51 normalize: whether to normalize identifiers according to the dialect of interest. 52 match_depth: whether to enforce that the table must match the schema's depth or not. 53 """
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.
55 @abc.abstractmethod 56 def column_names( 57 self, 58 table: exp.Table | str, 59 only_visible: bool = False, 60 dialect: DialectType = None, 61 normalize: bool | None = None, 62 ) -> Sequence[str]: 63 """ 64 Get the column names for a table. 65 66 Args: 67 table: the `Table` expression instance. 68 only_visible: whether to include invisible columns. 69 dialect: the SQL dialect that will be used to parse `table` if it's a string. 70 normalize: whether to normalize identifiers according to the dialect of interest. 71 72 Returns: 73 The sequence of column names. 74 """
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 sequence of column names.
76 @abc.abstractmethod 77 def get_column_type( 78 self, 79 table: exp.Table | str, 80 column: exp.Column | str, 81 dialect: DialectType = None, 82 normalize: bool | None = None, 83 ) -> exp.DataType: 84 """ 85 Get the `sqlglot.exp.DataType` type of a column in the schema. 86 87 Args: 88 table: the source table. 89 column: the target column. 90 dialect: the SQL dialect that will be used to parse `table` if it's a string. 91 normalize: whether to normalize identifiers according to the dialect of interest. 92 93 Returns: 94 The resulting column type. 95 """
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.
97 def has_column( 98 self, 99 table: exp.Table | str, 100 column: exp.Column | str, 101 dialect: DialectType = None, 102 normalize: bool | None = None, 103 ) -> bool: 104 """ 105 Returns whether `column` appears in `table`'s schema. 106 107 Args: 108 table: the source table. 109 column: the target column. 110 dialect: the SQL dialect that will be used to parse `table` if it's a string. 111 normalize: whether to normalize identifiers according to the dialect of interest. 112 113 Returns: 114 True if the column appears in the schema, False otherwise. 115 """ 116 name = column if isinstance(column, str) else column.name 117 return name in self.column_names(table, dialect=dialect, normalize=normalize)
Returns whether 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.
119 def get_udf_type( 120 self, 121 udf: exp.Anonymous | str, 122 dialect: DialectType = None, 123 normalize: bool | None = None, 124 ) -> exp.DataType: 125 """ 126 Get the return type of a UDF. 127 128 Args: 129 udf: the UDF expression or string. 130 dialect: the SQL dialect for parsing string arguments. 131 normalize: whether to normalize identifiers. 132 133 Returns: 134 The return type as a DataType, or UNKNOWN if not found. 135 """ 136 return exp.DType.UNKNOWN.into_expr()
Get the return type of a UDF.
Arguments:
- udf: the UDF expression or string.
- dialect: the SQL dialect for parsing string arguments.
- normalize: whether to normalize identifiers.
Returns:
The return type as a DataType, or UNKNOWN if not found.
151class AbstractMappingSchema: 152 def __init__( 153 self, 154 mapping: dict[str, object] | None = None, 155 udf_mapping: dict[str, object] | None = None, 156 ) -> None: 157 self.mapping: dict[str, object] = mapping or {} 158 self.mapping_trie: dict[str, object] = new_trie( 159 tuple(reversed(t)) for t in flatten_schema(self.mapping, depth=self.depth()) 160 ) 161 self.udf_mapping: dict[str, object] = udf_mapping or {} 162 self.udf_trie: dict[str, object] = new_trie( 163 tuple(reversed(t)) for t in flatten_schema(self.udf_mapping, depth=self.udf_depth()) 164 ) 165 166 self._supported_table_args: tuple[str, ...] = tuple() 167 168 @property 169 def empty(self) -> bool: 170 return not self.mapping 171 172 def depth(self) -> int: 173 return dict_depth(self.mapping) 174 175 def udf_depth(self) -> int: 176 return dict_depth(self.udf_mapping) 177 178 @property 179 def supported_table_args(self) -> tuple[str, ...]: 180 if not self._supported_table_args and self.mapping: 181 depth = self.depth() 182 183 if not depth: # None 184 self._supported_table_args = tuple() 185 elif 1 <= depth <= 3: 186 self._supported_table_args = exp.TABLE_PARTS[:depth] 187 else: 188 raise SchemaError(f"Invalid mapping shape. Depth: {depth}") 189 190 return self._supported_table_args 191 192 def table_parts(self, table: exp.Table) -> list[str]: 193 return [p.name for p in reversed(table.parts)] 194 195 def udf_parts(self, udf: exp.Anonymous) -> list[str]: 196 # a.b.c(...) is represented as Dot(Dot(a, b), Anonymous(c, ...)) 197 parent = udf.parent 198 parts = [p.name for p in parent.flatten()] if isinstance(parent, exp.Dot) else [udf.name] 199 return list(reversed(parts))[0 : self.udf_depth()] 200 201 def _find_in_trie( 202 self, 203 parts: list[str], 204 trie: dict[str, object], 205 raise_on_missing: bool, 206 ) -> list[str] | None: 207 value, trie = in_trie(trie, parts) 208 209 if value == TrieResult.FAILED: 210 return None 211 212 if value == TrieResult.PREFIX: 213 possibilities = flatten_schema(trie) 214 215 if len(possibilities) == 1: 216 parts.extend(possibilities[0]) 217 else: 218 if raise_on_missing: 219 joined_parts = ".".join(parts) 220 message = ", ".join(".".join(p) for p in possibilities) 221 raise SchemaError(f"Ambiguous mapping for {joined_parts}: {message}.") 222 223 return None 224 225 return parts 226 227 def find( 228 self, table: exp.Table, raise_on_missing: bool = True, ensure_data_types: bool = False 229 ) -> t.Any | None: 230 """ 231 Returns the schema of a given table. 232 233 Args: 234 table: the target table. 235 raise_on_missing: whether to raise in case the schema is not found. 236 ensure_data_types: whether to convert `str` types to their `DataType` equivalents. 237 238 Returns: 239 The schema of the target table. 240 """ 241 parts = self.table_parts(table)[0 : len(self.supported_table_args)] 242 resolved_parts = self._find_in_trie(parts, self.mapping_trie, raise_on_missing) 243 244 if resolved_parts is None: 245 return None 246 247 return self.nested_get(resolved_parts, raise_on_missing=raise_on_missing) 248 249 def find_udf(self, udf: exp.Anonymous, raise_on_missing: bool = False) -> t.Any | None: 250 """ 251 Returns the return type of a given UDF. 252 253 Args: 254 udf: the target UDF expression. 255 raise_on_missing: whether to raise if the UDF is not found. 256 257 Returns: 258 The return type of the UDF, or None if not found. 259 """ 260 parts = self.udf_parts(udf) 261 resolved_parts = self._find_in_trie(parts, self.udf_trie, raise_on_missing) 262 263 if resolved_parts is None: 264 return None 265 266 return nested_get( 267 self.udf_mapping, 268 *zip(resolved_parts, reversed(resolved_parts)), 269 raise_on_missing=raise_on_missing, 270 ) 271 272 def nested_get( 273 self, 274 parts: Sequence[str], 275 d: dict[str, object] | None = None, 276 raise_on_missing: bool = True, 277 ) -> t.Any | None: 278 return nested_get( 279 d or self.mapping, 280 *zip(self.supported_table_args, reversed(parts)), 281 raise_on_missing=raise_on_missing, 282 )
152 def __init__( 153 self, 154 mapping: dict[str, object] | None = None, 155 udf_mapping: dict[str, object] | None = None, 156 ) -> None: 157 self.mapping: dict[str, object] = mapping or {} 158 self.mapping_trie: dict[str, object] = new_trie( 159 tuple(reversed(t)) for t in flatten_schema(self.mapping, depth=self.depth()) 160 ) 161 self.udf_mapping: dict[str, object] = udf_mapping or {} 162 self.udf_trie: dict[str, object] = new_trie( 163 tuple(reversed(t)) for t in flatten_schema(self.udf_mapping, depth=self.udf_depth()) 164 ) 165 166 self._supported_table_args: tuple[str, ...] = tuple()
178 @property 179 def supported_table_args(self) -> tuple[str, ...]: 180 if not self._supported_table_args and self.mapping: 181 depth = self.depth() 182 183 if not depth: # None 184 self._supported_table_args = tuple() 185 elif 1 <= depth <= 3: 186 self._supported_table_args = exp.TABLE_PARTS[:depth] 187 else: 188 raise SchemaError(f"Invalid mapping shape. Depth: {depth}") 189 190 return self._supported_table_args
227 def find( 228 self, table: exp.Table, raise_on_missing: bool = True, ensure_data_types: bool = False 229 ) -> t.Any | None: 230 """ 231 Returns the schema of a given table. 232 233 Args: 234 table: the target table. 235 raise_on_missing: whether to raise in case the schema is not found. 236 ensure_data_types: whether to convert `str` types to their `DataType` equivalents. 237 238 Returns: 239 The schema of the target table. 240 """ 241 parts = self.table_parts(table)[0 : len(self.supported_table_args)] 242 resolved_parts = self._find_in_trie(parts, self.mapping_trie, raise_on_missing) 243 244 if resolved_parts is None: 245 return None 246 247 return self.nested_get(resolved_parts, raise_on_missing=raise_on_missing)
Returns the schema of a given table.
Arguments:
- table: the target table.
- raise_on_missing: whether to raise in case the schema is not found.
- ensure_data_types: whether to convert
strtypes to theirDataTypeequivalents.
Returns:
The schema of the target table.
249 def find_udf(self, udf: exp.Anonymous, raise_on_missing: bool = False) -> t.Any | None: 250 """ 251 Returns the return type of a given UDF. 252 253 Args: 254 udf: the target UDF expression. 255 raise_on_missing: whether to raise if the UDF is not found. 256 257 Returns: 258 The return type of the UDF, or None if not found. 259 """ 260 parts = self.udf_parts(udf) 261 resolved_parts = self._find_in_trie(parts, self.udf_trie, raise_on_missing) 262 263 if resolved_parts is None: 264 return None 265 266 return nested_get( 267 self.udf_mapping, 268 *zip(resolved_parts, reversed(resolved_parts)), 269 raise_on_missing=raise_on_missing, 270 )
Returns the return type of a given UDF.
Arguments:
- udf: the target UDF expression.
- raise_on_missing: whether to raise if the UDF is not found.
Returns:
The return type of the UDF, or None if not found.
285class MappingSchema(AbstractMappingSchema, Schema): 286 """ 287 Schema based on a nested mapping. 288 289 Args: 290 schema: Mapping in one of the following forms: 291 1. {table: {col: type}} 292 2. {db: {table: {col: type}}} 293 3. {catalog: {db: {table: {col: type}}}} 294 4. None - Tables will be added later 295 visible: Optional mapping of which columns in the schema are visible. If not provided, all columns 296 are assumed to be visible. The nesting should mirror that of the schema: 297 1. {table: set(*cols)}} 298 2. {db: {table: set(*cols)}}} 299 3. {catalog: {db: {table: set(*cols)}}}} 300 dialect: The dialect to be used for custom type mappings & parsing string arguments. 301 normalize: Whether to normalize identifier names according to the given dialect or not. 302 """ 303 304 def __init__( 305 self, 306 schema: dict[str, object] | None = None, 307 visible: dict[str, object] | None = None, 308 dialect: DialectType = None, 309 normalize: bool = True, 310 udf_mapping: dict[str, object] | None = None, 311 ) -> None: 312 self.visible: dict[str, object] = {} if visible is None else visible 313 self.normalize: bool = normalize 314 self._dialect: Dialect = Dialect.get_or_raise(dialect) 315 self._type_mapping_cache: dict[str, exp.DataType] = {} 316 self._normalized_table_cache: dict[tuple[exp.Table, DialectType, bool], exp.Table] = {} 317 self._normalized_name_cache: dict[tuple[str, DialectType, bool, bool], str] = {} 318 self._find_cache: dict[tuple[exp.Table, bool], dict[str, object] | None] = {} 319 self._depth: int = 0 320 schema = {} if schema is None else schema 321 udf_mapping = {} if udf_mapping is None else udf_mapping 322 323 super().__init__( 324 self._normalize(schema) if self.normalize else schema, 325 self._normalize_udfs(udf_mapping) if self.normalize else udf_mapping, 326 ) 327 328 @property 329 def dialect(self) -> Dialect: 330 """Returns the dialect for this mapping schema.""" 331 return self._dialect 332 333 @classmethod 334 def from_mapping_schema(cls, mapping_schema: MappingSchema) -> MappingSchema: 335 return MappingSchema( 336 schema=mapping_schema.mapping, 337 visible=mapping_schema.visible, 338 dialect=mapping_schema.dialect, 339 normalize=mapping_schema.normalize, 340 udf_mapping=mapping_schema.udf_mapping, 341 ) 342 343 def find( 344 self, table: exp.Table, raise_on_missing: bool = True, ensure_data_types: bool = False 345 ) -> t.Any | None: 346 cache_key = (table, ensure_data_types) 347 schema = self._find_cache.get(cache_key) 348 349 if schema is None: 350 schema = super().find(table, raise_on_missing=raise_on_missing) 351 if ensure_data_types and isinstance(schema, dict): 352 schema = { 353 col: self._to_data_type(dtype) if isinstance(dtype, str) else dtype 354 for col, dtype in schema.items() 355 } 356 self._find_cache[cache_key] = schema 357 358 return schema 359 360 def copy( 361 self, schema: dict[str, object] | None = None, **kwargs: Unpack[SchemaArgs] 362 ) -> MappingSchema: 363 mapping_kwargs: SchemaArgs = { 364 "visible": self.visible.copy(), 365 "dialect": self.dialect, 366 "normalize": self.normalize, 367 "udf_mapping": self.udf_mapping.copy(), 368 **kwargs, 369 } 370 return MappingSchema(self.mapping.copy() if schema is None else schema, **mapping_kwargs) 371 372 def add_table( 373 self, 374 table: exp.Table | str, 375 column_mapping: ColumnMapping | None = None, 376 dialect: DialectType = None, 377 normalize: bool | None = None, 378 match_depth: bool = True, 379 ) -> None: 380 """ 381 Register or update a table. Updates are only performed if a new column mapping is provided. 382 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 383 384 Args: 385 table: the `Table` expression instance or string representing the table. 386 column_mapping: a column mapping that describes the structure of the table. 387 dialect: the SQL dialect that will be used to parse `table` if it's a string. 388 normalize: whether to normalize identifiers according to the dialect of interest. 389 match_depth: whether to enforce that the table must match the schema's depth or not. 390 """ 391 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 392 393 if match_depth and not self.empty and len(normalized_table.parts) != self.depth(): 394 raise SchemaError( 395 f"Table {normalized_table.sql(dialect=self.dialect)} must match the " 396 f"schema's nesting level: {self.depth()}." 397 ) 398 399 normalized_column_mapping = { 400 self._normalize_name(key, dialect=dialect, normalize=normalize): value 401 for key, value in ensure_column_mapping(column_mapping).items() 402 } 403 404 schema = self.find(normalized_table, raise_on_missing=False) 405 if schema and not normalized_column_mapping: 406 return 407 408 parts = self.table_parts(normalized_table) 409 410 nested_set(self.mapping, tuple(reversed(parts)), normalized_column_mapping) 411 new_trie([parts], self.mapping_trie) 412 self._find_cache.pop((normalized_table, True), None) 413 self._find_cache.pop((normalized_table, False), None) 414 415 def column_names( 416 self, 417 table: exp.Table | str, 418 only_visible: bool = False, 419 dialect: DialectType = None, 420 normalize: bool | None = None, 421 ) -> list[str]: 422 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 423 424 schema: dict[str, object] | None = self.find(normalized_table) 425 if schema is None: 426 return [] 427 428 if not only_visible or not self.visible: 429 return list(schema) 430 431 visible = self.nested_get(self.table_parts(normalized_table), self.visible) or [] 432 return [col for col in schema if col in visible] 433 434 def get_column_type( 435 self, 436 table: exp.Table | str, 437 column: exp.Column | str, 438 dialect: DialectType = None, 439 normalize: bool | None = None, 440 ) -> exp.DataType: 441 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 442 443 normalized_column_name = self._normalize_name( 444 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 445 ) 446 447 table_schema: dict[str, object] | None = self.find(normalized_table, raise_on_missing=False) 448 if table_schema: 449 column_type = table_schema.get(normalized_column_name) 450 451 if isinstance(column_type, exp.DataType): 452 return column_type 453 elif isinstance(column_type, str): 454 return self._to_data_type(column_type, dialect=dialect) 455 456 return exp.DType.UNKNOWN.into_expr() 457 458 def get_udf_type( 459 self, 460 udf: exp.Anonymous | str, 461 dialect: DialectType = None, 462 normalize: bool | None = None, 463 ) -> exp.DataType: 464 """ 465 Get the return type of a UDF. 466 467 Args: 468 udf: the UDF expression or string (e.g., "db.my_func()"). 469 dialect: the SQL dialect for parsing string arguments. 470 normalize: whether to normalize identifiers. 471 472 Returns: 473 The return type as a DataType, or UNKNOWN if not found. 474 """ 475 parts = self._normalize_udf(udf, dialect=dialect, normalize=normalize) 476 resolved_parts = self._find_in_trie(parts, self.udf_trie, raise_on_missing=False) 477 478 if resolved_parts is None: 479 return exp.DType.UNKNOWN.into_expr() 480 481 udf_type = nested_get( 482 self.udf_mapping, 483 *zip(resolved_parts, reversed(resolved_parts)), 484 raise_on_missing=False, 485 ) 486 487 if isinstance(udf_type, exp.DataType): 488 return udf_type 489 elif isinstance(udf_type, str): 490 return self._to_data_type(udf_type, dialect=dialect) 491 492 return exp.DType.UNKNOWN.into_expr() 493 494 def has_column( 495 self, 496 table: exp.Table | str, 497 column: exp.Column | str, 498 dialect: DialectType = None, 499 normalize: bool | None = None, 500 ) -> bool: 501 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 502 503 normalized_column_name = self._normalize_name( 504 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 505 ) 506 507 table_schema: dict[str, object] | None = self.find(normalized_table, raise_on_missing=False) 508 return normalized_column_name in table_schema if table_schema else False 509 510 def _normalize(self, schema: dict[str, object]) -> dict[str, object]: 511 """ 512 Normalizes all identifiers in the schema. 513 514 Args: 515 schema: the schema to normalize. 516 517 Returns: 518 The normalized schema mapping. 519 """ 520 normalized_mapping: dict[str, object] = {} 521 flattened_schema = flatten_schema(schema) 522 error_msg = "Table {} must match the schema's nesting level: {}." 523 524 for keys in flattened_schema: 525 columns = nested_get(schema, *zip(keys, keys)) 526 527 if not isinstance(columns, dict): 528 raise SchemaError(error_msg.format(".".join(keys[:-1]), len(flattened_schema[0]))) 529 if not columns: 530 raise SchemaError(f"Table {'.'.join(keys[:-1])} must have at least one column") 531 if isinstance(first(columns.values()), dict): 532 raise SchemaError( 533 error_msg.format( 534 ".".join(keys + flatten_schema(columns)[0]), len(flattened_schema[0]) 535 ), 536 ) 537 538 normalized_keys = [self._normalize_name(key, is_table=True) for key in keys] 539 for column_name, column_type in columns.items(): 540 nested_set( 541 normalized_mapping, 542 normalized_keys + [self._normalize_name(column_name)], 543 column_type, 544 ) 545 546 return normalized_mapping 547 548 def _normalize_udfs(self, udfs: dict[str, object]) -> dict[str, object]: 549 """ 550 Normalizes all identifiers in the UDF mapping. 551 552 Args: 553 udfs: the UDF mapping to normalize. 554 555 Returns: 556 The normalized UDF mapping. 557 """ 558 normalized_mapping: dict[str, object] = {} 559 560 for keys in flatten_schema(udfs, depth=dict_depth(udfs)): 561 udf_type = nested_get(udfs, *zip(keys, keys)) 562 normalized_keys = [self._normalize_name(key, is_table=True) for key in keys] 563 nested_set(normalized_mapping, normalized_keys, udf_type) 564 565 return normalized_mapping 566 567 def _normalize_udf( 568 self, 569 udf: exp.Anonymous | str, 570 dialect: DialectType = None, 571 normalize: bool | None = None, 572 ) -> list[str]: 573 """ 574 Extract and normalize UDF parts for lookup. 575 576 Args: 577 udf: the UDF expression or qualified string (e.g., "db.my_func()"). 578 dialect: the SQL dialect for parsing. 579 normalize: whether to normalize identifiers. 580 581 Returns: 582 A list of normalized UDF parts (reversed for trie lookup). 583 """ 584 dialect = dialect or self.dialect 585 normalize = self.normalize if normalize is None else normalize 586 587 if isinstance(udf, str): 588 parsed: exp.Expr = exp.maybe_parse(udf, dialect=dialect) 589 590 if isinstance(parsed, exp.Anonymous): 591 udf = parsed 592 elif isinstance(parsed, exp.Dot) and isinstance(parsed.expression, exp.Anonymous): 593 udf = parsed.expression 594 else: 595 raise SchemaError(f"Unable to parse UDF from: {udf!r}") 596 parts = self.udf_parts(udf) 597 598 if normalize: 599 parts = [self._normalize_name(part, dialect=dialect, is_table=True) for part in parts] 600 601 return parts 602 603 def _normalize_table( 604 self, 605 table: exp.Table | str, 606 dialect: DialectType = None, 607 normalize: bool | None = None, 608 ) -> exp.Table: 609 dialect = dialect or self.dialect 610 normalize = self.normalize if normalize is None else normalize 611 612 # Cache normalized tables by object id for exp.Table inputs 613 # This is effective when the same Table object is looked up multiple times 614 if isinstance(table, exp.Table) and ( 615 cached := self._normalized_table_cache.get((table, dialect, normalize)) 616 ): 617 return cached 618 619 normalized_table = exp.maybe_parse(table, into=exp.Table, dialect=dialect, copy=normalize) 620 621 if normalize: 622 for part in normalized_table.parts: 623 if isinstance(part, exp.Identifier): 624 part.replace( 625 normalize_name(part, dialect=dialect, is_table=True, normalize=normalize) 626 ) 627 628 self._normalized_table_cache[(normalized_table, dialect, normalize)] = normalized_table 629 return normalized_table 630 631 def _normalize_name( 632 self, 633 name: str | exp.Identifier, 634 dialect: DialectType = None, 635 is_table: bool = False, 636 normalize: bool | None = None, 637 ) -> str: 638 normalize = self.normalize if normalize is None else normalize 639 640 dialect = dialect or self.dialect 641 name_str = name if isinstance(name, str) else name.name 642 cache_key = (name_str, dialect, is_table, normalize) 643 644 if cached := self._normalized_name_cache.get(cache_key): 645 return cached 646 647 result = normalize_name( 648 name, 649 dialect=dialect, 650 is_table=is_table, 651 normalize=normalize, 652 ).name 653 654 self._normalized_name_cache[cache_key] = result 655 return result 656 657 def depth(self) -> int: 658 if not self.empty and not self._depth: 659 # The columns themselves are a mapping, but we don't want to include those 660 self._depth = super().depth() - 1 661 return self._depth 662 663 def _to_data_type(self, schema_type: str, dialect: DialectType = None) -> exp.DataType: 664 """ 665 Convert a type represented as a string to the corresponding `sqlglot.exp.DataType` object. 666 667 Args: 668 schema_type: the type we want to convert. 669 dialect: the SQL dialect that will be used to parse `schema_type`, if needed. 670 671 Returns: 672 The resulting expression type. 673 """ 674 if schema_type not in self._type_mapping_cache: 675 dialect = Dialect.get_or_raise(dialect) if dialect else self.dialect 676 udt = dialect.SUPPORTS_USER_DEFINED_TYPES 677 678 try: 679 expression = exp.DataType.from_str(schema_type, dialect=dialect, udt=udt) 680 expression.transform(dialect.normalize_identifier, copy=False) 681 self._type_mapping_cache[schema_type] = expression 682 except AttributeError: 683 in_dialect = f" in dialect {dialect}" if dialect else "" 684 raise SchemaError(f"Failed to build type '{schema_type}'{in_dialect}.") 685 686 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.
304 def __init__( 305 self, 306 schema: dict[str, object] | None = None, 307 visible: dict[str, object] | None = None, 308 dialect: DialectType = None, 309 normalize: bool = True, 310 udf_mapping: dict[str, object] | None = None, 311 ) -> None: 312 self.visible: dict[str, object] = {} if visible is None else visible 313 self.normalize: bool = normalize 314 self._dialect: Dialect = Dialect.get_or_raise(dialect) 315 self._type_mapping_cache: dict[str, exp.DataType] = {} 316 self._normalized_table_cache: dict[tuple[exp.Table, DialectType, bool], exp.Table] = {} 317 self._normalized_name_cache: dict[tuple[str, DialectType, bool, bool], str] = {} 318 self._find_cache: dict[tuple[exp.Table, bool], dict[str, object] | None] = {} 319 self._depth: int = 0 320 schema = {} if schema is None else schema 321 udf_mapping = {} if udf_mapping is None else udf_mapping 322 323 super().__init__( 324 self._normalize(schema) if self.normalize else schema, 325 self._normalize_udfs(udf_mapping) if self.normalize else udf_mapping, 326 )
328 @property 329 def dialect(self) -> Dialect: 330 """Returns the dialect for this mapping schema.""" 331 return self._dialect
Returns the dialect for this mapping schema.
333 @classmethod 334 def from_mapping_schema(cls, mapping_schema: MappingSchema) -> MappingSchema: 335 return MappingSchema( 336 schema=mapping_schema.mapping, 337 visible=mapping_schema.visible, 338 dialect=mapping_schema.dialect, 339 normalize=mapping_schema.normalize, 340 udf_mapping=mapping_schema.udf_mapping, 341 )
343 def find( 344 self, table: exp.Table, raise_on_missing: bool = True, ensure_data_types: bool = False 345 ) -> t.Any | None: 346 cache_key = (table, ensure_data_types) 347 schema = self._find_cache.get(cache_key) 348 349 if schema is None: 350 schema = super().find(table, raise_on_missing=raise_on_missing) 351 if ensure_data_types and isinstance(schema, dict): 352 schema = { 353 col: self._to_data_type(dtype) if isinstance(dtype, str) else dtype 354 for col, dtype in schema.items() 355 } 356 self._find_cache[cache_key] = schema 357 358 return schema
Returns the schema of a given table.
Arguments:
- table: the target table.
- raise_on_missing: whether to raise in case the schema is not found.
- ensure_data_types: whether to convert
strtypes to theirDataTypeequivalents.
Returns:
The schema of the target table.
360 def copy( 361 self, schema: dict[str, object] | None = None, **kwargs: Unpack[SchemaArgs] 362 ) -> MappingSchema: 363 mapping_kwargs: SchemaArgs = { 364 "visible": self.visible.copy(), 365 "dialect": self.dialect, 366 "normalize": self.normalize, 367 "udf_mapping": self.udf_mapping.copy(), 368 **kwargs, 369 } 370 return MappingSchema(self.mapping.copy() if schema is None else schema, **mapping_kwargs)
372 def add_table( 373 self, 374 table: exp.Table | str, 375 column_mapping: ColumnMapping | None = None, 376 dialect: DialectType = None, 377 normalize: bool | None = None, 378 match_depth: bool = True, 379 ) -> None: 380 """ 381 Register or update a table. Updates are only performed if a new column mapping is provided. 382 The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. 383 384 Args: 385 table: the `Table` expression instance or string representing the table. 386 column_mapping: a column mapping that describes the structure of the table. 387 dialect: the SQL dialect that will be used to parse `table` if it's a string. 388 normalize: whether to normalize identifiers according to the dialect of interest. 389 match_depth: whether to enforce that the table must match the schema's depth or not. 390 """ 391 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 392 393 if match_depth and not self.empty and len(normalized_table.parts) != self.depth(): 394 raise SchemaError( 395 f"Table {normalized_table.sql(dialect=self.dialect)} must match the " 396 f"schema's nesting level: {self.depth()}." 397 ) 398 399 normalized_column_mapping = { 400 self._normalize_name(key, dialect=dialect, normalize=normalize): value 401 for key, value in ensure_column_mapping(column_mapping).items() 402 } 403 404 schema = self.find(normalized_table, raise_on_missing=False) 405 if schema and not normalized_column_mapping: 406 return 407 408 parts = self.table_parts(normalized_table) 409 410 nested_set(self.mapping, tuple(reversed(parts)), normalized_column_mapping) 411 new_trie([parts], self.mapping_trie) 412 self._find_cache.pop((normalized_table, True), None) 413 self._find_cache.pop((normalized_table, False), None)
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.
415 def column_names( 416 self, 417 table: exp.Table | str, 418 only_visible: bool = False, 419 dialect: DialectType = None, 420 normalize: bool | None = None, 421 ) -> list[str]: 422 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 423 424 schema: dict[str, object] | None = self.find(normalized_table) 425 if schema is None: 426 return [] 427 428 if not only_visible or not self.visible: 429 return list(schema) 430 431 visible = self.nested_get(self.table_parts(normalized_table), self.visible) or [] 432 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 sequence of column names.
434 def get_column_type( 435 self, 436 table: exp.Table | str, 437 column: exp.Column | str, 438 dialect: DialectType = None, 439 normalize: bool | None = None, 440 ) -> exp.DataType: 441 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 442 443 normalized_column_name = self._normalize_name( 444 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 445 ) 446 447 table_schema: dict[str, object] | None = self.find(normalized_table, raise_on_missing=False) 448 if table_schema: 449 column_type = table_schema.get(normalized_column_name) 450 451 if isinstance(column_type, exp.DataType): 452 return column_type 453 elif isinstance(column_type, str): 454 return self._to_data_type(column_type, dialect=dialect) 455 456 return exp.DType.UNKNOWN.into_expr()
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.
458 def get_udf_type( 459 self, 460 udf: exp.Anonymous | str, 461 dialect: DialectType = None, 462 normalize: bool | None = None, 463 ) -> exp.DataType: 464 """ 465 Get the return type of a UDF. 466 467 Args: 468 udf: the UDF expression or string (e.g., "db.my_func()"). 469 dialect: the SQL dialect for parsing string arguments. 470 normalize: whether to normalize identifiers. 471 472 Returns: 473 The return type as a DataType, or UNKNOWN if not found. 474 """ 475 parts = self._normalize_udf(udf, dialect=dialect, normalize=normalize) 476 resolved_parts = self._find_in_trie(parts, self.udf_trie, raise_on_missing=False) 477 478 if resolved_parts is None: 479 return exp.DType.UNKNOWN.into_expr() 480 481 udf_type = nested_get( 482 self.udf_mapping, 483 *zip(resolved_parts, reversed(resolved_parts)), 484 raise_on_missing=False, 485 ) 486 487 if isinstance(udf_type, exp.DataType): 488 return udf_type 489 elif isinstance(udf_type, str): 490 return self._to_data_type(udf_type, dialect=dialect) 491 492 return exp.DType.UNKNOWN.into_expr()
Get the return type of a UDF.
Arguments:
- udf: the UDF expression or string (e.g., "db.my_func()").
- dialect: the SQL dialect for parsing string arguments.
- normalize: whether to normalize identifiers.
Returns:
The return type as a DataType, or UNKNOWN if not found.
494 def has_column( 495 self, 496 table: exp.Table | str, 497 column: exp.Column | str, 498 dialect: DialectType = None, 499 normalize: bool | None = None, 500 ) -> bool: 501 normalized_table = self._normalize_table(table, dialect=dialect, normalize=normalize) 502 503 normalized_column_name = self._normalize_name( 504 column if isinstance(column, str) else column.this, dialect=dialect, normalize=normalize 505 ) 506 507 table_schema: dict[str, object] | None = self.find(normalized_table, raise_on_missing=False) 508 return normalized_column_name in table_schema if table_schema else False
Returns whether 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.
689def normalize_name( 690 identifier: str | exp.Identifier, 691 dialect: DialectType = None, 692 is_table: bool = False, 693 normalize: bool | None = True, 694) -> exp.Identifier: 695 if isinstance(identifier, str): 696 identifier = exp.parse_identifier(identifier, dialect=dialect) 697 elif normalize: 698 identifier = identifier.copy() 699 700 if not normalize: 701 return identifier 702 703 # this is used for normalize_identifier, bigquery has special rules pertaining tables 704 identifier.meta["is_table"] = is_table 705 return Dialect.get_or_raise(dialect).normalize_identifier(identifier)
717def ensure_column_mapping(mapping: ColumnMapping | None) -> dict[str, t.Any]: 718 if mapping is None: 719 return {} 720 elif isinstance(mapping, dict): 721 return mapping 722 elif isinstance(mapping, str): 723 col_name_type_strs = [x.strip() for x in mapping.split(",")] 724 return { 725 name_type_str.split(":")[0].strip(): name_type_str.split(":")[1].strip() 726 for name_type_str in col_name_type_strs 727 } 728 elif isinstance(mapping, list): 729 return {x.strip(): None for x in mapping} 730 731 raise ValueError(f"Invalid mapping provided: {type(mapping)}")
734def flatten_schema( 735 schema: dict[str, object], depth: int | None = None, keys: list[str] | None = None 736) -> list[list[str]]: 737 tables: list[list[str]] = [] 738 keys = keys or [] 739 depth = dict_depth(schema) - 1 if depth is None else depth 740 741 for k, v in schema.items(): 742 if depth == 1 or not isinstance(v, dict): 743 tables.append(keys + [k]) 744 elif depth >= 2: 745 tables.extend(flatten_schema(v, depth - 1, keys + [k])) 746 747 return tables
750def nested_get( 751 d: dict[str, object], *path: tuple[str, str], raise_on_missing: bool = True 752) -> t.Any | None: 753 """ 754 Get a value for a nested dictionary. 755 756 Args: 757 d: the dictionary to search. 758 *path: tuples of (name, key), where: 759 `key` is the key in the dictionary to get. 760 `name` is a string to use in the error if `key` isn't found. 761 762 Returns: 763 The value or None if it doesn't exist. 764 """ 765 result: t.Any = d 766 for name, key in path: 767 result = result.get(key) 768 if result is None: 769 if raise_on_missing: 770 name = "table" if name == "this" else name 771 raise ValueError(f"Unknown {name}: {key}") 772 return None 773 774 return result
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.
777def nested_set(d: dict[str, t.Any], keys: Sequence[str], value: t.Any) -> dict[str, t.Any]: 778 """ 779 In-place set a value for a nested dictionary 780 781 Example: 782 >>> nested_set({}, ["top_key", "second_key"], "value") 783 {'top_key': {'second_key': 'value'}} 784 785 >>> nested_set({"top_key": {"third_key": "third_value"}}, ["top_key", "second_key"], "value") 786 {'top_key': {'third_key': 'third_value', 'second_key': 'value'}} 787 788 Args: 789 d: dictionary to update. 790 keys: the keys that makeup the path to `value`. 791 value: the value to set in the dictionary for the given key path. 792 793 Returns: 794 The (possibly) updated dictionary. 795 """ 796 if not keys: 797 return d 798 799 if len(keys) == 1: 800 d[keys[0]] = value 801 return d 802 803 subd = d 804 for key in keys[:-1]: 805 if key not in subd: 806 subd = subd.setdefault(key, {}) 807 else: 808 subd = subd[key] 809 810 subd[keys[-1]] = value 811 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.