def test_sa_model_info_extraction__STI_Employee(): """ Test sa_model_info(STI_Employee) """ assert set(sa_model_info(STI_Employee, types=AttributeType.ALL, exclude=())) == { 'id', 'name', 'type', } assert set(sa_model_info(STI_Manager, types=AttributeType.ALL, exclude=())) == { 'id', 'name', 'type', # employee fields # additional fields 'manager_data', 'company_id', 'company', } assert set(sa_model_info(STI_Engineer, types=AttributeType.ALL, exclude=())) == { 'id', 'name', 'type', # employee fields # additional fields 'engineer_info', }
def sa_model_fields( Model: type, *, types: AttributeType = AttributeType.COLUMN, make_optional: FilterFunctionT, only_readable: bool = False, only_writable: bool = False, exclude: FilterT = (), can_omit_nullable: bool = True, naming: ModelNameMakerFunction, ) -> Dict[str, Tuple[type, Field]]: """ Take an SqlAlchemy model and generate pydantic Field()s from it It will use sa_model_info() to extract attribute information from the SqlAlchemy model. Only fields selected by `types` & `exclude` will be considered. If SqlAlchemy model contains type annotations, they will override column types. Args: Model: the model to generate fields from types: attribute types to include. See AttributeType make_optional: a function(name)->bool that selects fields to make Optional[] only_readable: only include fields that are readable only_writable: only include fields that are writable exclude: the list of fields to ignore, or a filter(name) to exclude fields dynamically. See also: sa2schema.filters for useful presets can_omit_nullable: `False` to make nullable fields and fields with defaults required. naming: optionally, a callable(Model) naming pattern generator. This is required for resolving relationship targets. If relationships aren't used, provide some exception thrower. Returns: a dict: attribute names => (type, Field) """ # Model annotations will override any Column types model_annotations = getattr(Model, '__annotations__', {}) model_annotations = resolve_annotations(model_annotations, Model.__module__) # Walk attributes attributes = [ (name, info, make_optional(name)) for name, info in sa_model_info( Model, types=types, exclude=exclude).items() if (not only_readable or info.readable) and ( not only_writable or info.writable) and # Hardcoded for now. (not name.startswith('_') ) # exclude private properties. Consistent with Pydantic behavior. ] # Generate Field()s return { name: ( # Field type pydantic_field_type(name, info, model_annotations, made_optional, naming), # Field() object make_field(info, made_optional, can_omit_nullable=can_omit_nullable), ) for name, info, made_optional in attributes }
def __init__(self, instance: object, *, copy: bool = False): """ Make a lightweight snapshot of an instance. Be sure to do it before flush(), because flush() will erase all in-memory changes. Args: instance: The instance to get the historical values for. copy: Copy every mutable value. Useful for embedded dictionaries, but it a bit more expensive, so disabled by default. """ # Model info self.__model_info = sa_model_info(type(instance), types=AttributeType.ALL) # Remember the historical values self.__state: InstanceState = instance_state(instance) self.__historical = { # Merging dictionaries is very cheap **self.__state.dict, # current values **self.__state.committed_state, # overwritten with DB values } # Make a deep copy to preserve embedded dictionaries if copy: self.__historical = deepcopy(self.__historical)
def test_sa_model_info_extraction__JTI_Company(): """ Test sa_model_info(JTI_Company) """ generated_fields = sa_model_info(JTI_Company, types=AttributeType.ALL, exclude=()) expected_fields = { 'id': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=JTI_Company.id, primary_key=True, foreign_key=False, nullable=False, # primary key readable=True, writable=True, value_type=int, default=NOT_PROVIDED, # because not nullable default_factory=None, doc=None), 'name': ColumnInfo(attribute_type=AttributeType.COLUMN, attribute=JTI_Company.name, primary_key=False, foreign_key=False, nullable=True, readable=True, writable=True, value_type=str, default=None, default_factory=None, doc=None), 'employees': RelationshipInfo(attribute_type=AttributeType.RELATIONSHIP, attribute=JTI_Company.employees, nullable=False, readable=True, writable=True, value_type=List[JTI_Employee], target_model=JTI_Employee, uselist=True, collection_class=list, default=NOT_PROVIDED, default_factory=list, doc=None), } assert generated_fields == expected_fields # Compare final values assert { name: attr.final_value_type for name, attr in generated_fields.items() } == { 'id': int, 'name': Optional[str], 'employees': List[JTI_Employee], }
def primary_and_foreign_keys_from(model: type) -> Iterable[sa.orm.attributes.InstrumentedAttribute]: """ Get model attributes: primary and foreign keys """ columns_info = sa2schema.sa_model_info(model, types=sa2schema.AttributeType.COLUMN) attr_info: sa2schema.info.ColumnInfo return [ attr_info.attribute for attr_name, attr_info in columns_info.items() if attr_info.primary_key or attr_info.foreign_key ]
def test_sa_model_info_extraction__JTI_Employee(): """ Test sa_model_info(JTI_Company) """ generated_fields = sa_model_info(JTI_Employee, types=AttributeType.ALL, exclude=()) assert set(generated_fields) == { 'id', 'name', 'type', 'company_id', 'company', } assert generated_fields['company'] == RelationshipInfo( attribute_type=AttributeType.RELATIONSHIP, attribute=JTI_Employee.company, nullable=True, readable=True, writable=True, value_type=JTI_Company, target_model=JTI_Company, uselist=False, collection_class=None, default=None, default_factory=None, doc=None) # Engineer is the same: inherits fields generated_fields = sa_model_info(JTI_Engineer, types=AttributeType.ALL, exclude=()) assert set(generated_fields) == { 'id', 'name', 'type', 'company_id', 'company', # inherited # plus one more field 'engineer_name', }
def get_all_safely_loadable_properties(Model: type): """ Get all properties with @loads_attributes Returns: { property-name => set(attribute-names) } """ all_properties = sa2.sa_model_info(Model, types=AttributeType.PROPERTY_R | AttributeType.HYBRID_PROPERTY_R) return { property_name: property_info.loads_attributes for property_name, property_info in all_properties.items() if property_info.loads_attributes }
def from_sa_model(cls, model: type): """ Extract structural information from a pydantic model """ info = sa_model_info(model, types=AttributeType.ALL) return cls( name=model.__name__, docstring=model.__doc__ or '', fields=[ ModelFieldInfo( name=name, type=attr_info.final_value_type, comment=attr_info.doc, ) for name, attr_info in info.items() ] )
def validate_saves_custom_fields_handles_every_relationship(settings: CrudSettings, CrudHandler: type): """ Validate: every writable relationship is covered by @saves_custom_fields() """ # Only validate if going to do saving if any([settings.CreateInputSchema, settings.UpdateInputSchema, settings.CreateOrUpdateInputSchema]): # Get all relationships relationship_names = set(sa2schema.sa_model_info(settings.Model, types=sa2schema.AttributeType.RELATIONSHIP)) # Get relationship names covered by somethind handled_relationships = set() # @saves_custom_fields handled_relationships |= crudbase.saves_custom_fields.all_field_names_from(CrudHandler) # exclude handled_relationships |= settings._exclude_on_create | settings._exclude_on_update # Compare unhandled_relationships = relationship_names - handled_relationships assert not unhandled_relationships, ( f'The following relationships have not been handled by @saves_custom_fields(): ' f'{unhandled_relationships!r}. ' f'Either implement save handlers with @saves_custom_fields(), ' f'or exclude them explicitly (`ro_relations`).' )
def for_model(self, Model: SAModelT): super().for_model(Model) self.model_info = sa2schema.sa_model_info(Model, types=AttributeType.ALL)
def for_model(self, Model: SAModelT): super().for_model(Model) self.model_info = sa2schema.sa_model_info(Model, types=self.types, exclude=self.exclude)
def test_field_filter(exclude: FilterT, expected_fields: Set[str]): model_info = sa2.sa_model_info(models.User, types=AttributeType.ALL, exclude=exclude) assert set(model_info) == set(expected_fields)
def test_sa_model_info_extraction__User(): """ Test sa_model_info(User) """ generated_fields = sa_model_info(User, types=AttributeType.ALL, exclude=()) expected_fields = { '_ignored': ColumnInfo( # not ignored in sa_model_info() ; ignored in sa_model() attribute_type=AttributeType.COLUMN, attribute=User._ignored, primary_key=False, foreign_key=False, nullable=True, readable=True, writable=True, value_type=str, default=None, default_factory=None, doc=None), 'annotated_int': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=User.annotated_int, primary_key=True, foreign_key=False, nullable=False, readable=True, writable=True, value_type=str, # annotations are ignored here default=NOT_PROVIDED, default_factory=None, doc=None), 'int': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=User.int, primary_key=False, foreign_key=False, nullable=True, readable=True, writable=True, value_type=int, # type is here default=None, # nullable columns always have this default default_factory=None, doc=None), 'enum': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=User.enum, primary_key=False, foreign_key=False, nullable=True, readable=True, writable=True, value_type=EnumType, # type is here default=None, # nullable column default_factory=None, doc=None), 'optional': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=User.optional, primary_key=False, foreign_key=False, nullable=True, # optional readable=True, writable=True, value_type=str, # type is not wrapped in Optional[] default=None, # nullable column default_factory=None, doc=None), 'required': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=User.required, primary_key=False, foreign_key=False, nullable=False, # required readable=True, writable=True, value_type=str, default=NOT_PROVIDED, # non-nullable columns get this default_factory=None, doc=None), 'default': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=User.default, primary_key=False, foreign_key=False, nullable=False, # not nullable readable=True, writable=True, value_type=str, default='value', # default value default_factory=None, doc=None), 'documented': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=User.documented, primary_key=False, foreign_key=False, nullable=True, readable=True, writable=True, value_type=str, default=None, # nullable column default_factory=None, doc='Some descriptive text' # doc=text ), 'json_attr': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=User.json_attr, primary_key=False, foreign_key=False, nullable=True, readable=True, writable=True, # JSON defaults to dict in SqlAlchemy value_type=dict, default=None, # nullable column default_factory=None, doc=None), 'property_without_type': PropertyInfo( attribute_type=AttributeType.PROPERTY_R, # no setter attribute=User.property_without_type, nullable=True, # no type. No idea. May be null as well. readable=True, writable=False, # no setter loads_attributes=None, value_type=Any, # no idea default=NOT_PROVIDED, default_factory=None, doc=None), 'property_typed': PropertyInfo( attribute_type=AttributeType.PROPERTY_R, # no setter attribute=User.property_typed, nullable=False, # return value is not Optional[] readable=True, writable=False, # no setter loads_attributes=None, value_type=str, default=NOT_PROVIDED, default_factory=None, doc=None), 'property_documented': PropertyInfo( attribute_type=AttributeType.PROPERTY_R, # no setter attribute=User.property_documented, nullable=True, # no return value. May be null. readable=True, writable=False, # no setter loads_attributes={'documented'}, # read from @loads_attributes() value_type=Any, # no return value default=NOT_PROVIDED, default_factory=None, doc=' Documented property '), 'property_nullable': PropertyInfo( attribute_type=AttributeType.PROPERTY_R, # no setter attribute=User.property_nullable, nullable=True, # explicitly Optional[] readable=True, writable=False, # no setter loads_attributes=None, value_type=str, # unwrapped default=NOT_PROVIDED, default_factory=None, doc=None, ), 'property_writable': PropertyInfo( attribute_type=AttributeType.PROPERTY_RW, # setter provided attribute=User.property_writable, nullable=False, # no Optional[] readable=True, writable=True, # with setter loads_attributes=None, value_type=str, # type default='default', # from setter's argument default_factory=None, doc=None, ), 'hybrid_property_typed': HybridPropertyInfo( attribute_type=AttributeType.HYBRID_PROPERTY_R, # no setter attribute=User.hybrid_property_typed.descriptor, nullable=False, # no Optional[] readable=True, writable=False, # no setter loads_attributes=None, value_type=str, # type default=NOT_PROVIDED, default_factory=None, doc=None, ), 'hybrid_property_writable': HybridPropertyInfo( attribute_type=AttributeType.HYBRID_PROPERTY_RW, # setter attribute=User.hybrid_property_writable.descriptor, nullable=False, # no Optional[] readable=True, writable=True, # setter loads_attributes=None, value_type=str, # type default='default', # from setter's argument default_factory=None, doc=None, ), 'hybrid_method_attr': HybridMethodInfo( attribute_type=AttributeType.HYBRID_METHOD, attribute=User.__mapper__.all_orm_descriptors.hybrid_method_attr, nullable=True, readable=True, writable=False, value_type=Any, # no return annotation default=NOT_PROVIDED, default_factory=None, doc=None, ), 'expression': ColumnExpressionInfo( attribute_type=AttributeType.EXPRESSION, attribute=User.expression, nullable=True, readable=True, writable=False, value_type=int, # sqlalchemy knows! default=NOT_PROVIDED, default_factory=None, doc=None, ), 'point': CompositeInfo( attribute_type=AttributeType.COMPOSITE, attribute=User.point, nullable=False, # composites aren't nullable readable=True, writable=True, # it's wriable value_type=Point, # composite type default=NOT_PROVIDED, default_factory=None, doc=None, ), 'synonym': CompositeInfo( # COMPLETELY copies the 'point' it points to! attribute_type=AttributeType.COMPOSITE, attribute=User.synonym, nullable=False, readable=True, writable=True, value_type=Point, default=NOT_PROVIDED, default_factory=None, doc=None, ), 'articles_list': RelationshipInfo( attribute_type=AttributeType.RELATIONSHIP, attribute=User.articles_list, nullable=False, # "no" when `uselist` readable=True, writable=True, value_type=List[Article], # target model, collection target_model=Article, uselist=True, collection_class=list, default=NOT_PROVIDED, default_factory=list, doc=None, ), 'articles_set': RelationshipInfo( attribute_type=AttributeType.RELATIONSHIP, attribute=User.articles_set, nullable=False, # "no" when `uselist` readable=True, writable=False, # `viewonly` is set value_type=Set[Article], # target model, collection target_model=Article, uselist=True, collection_class=set, default=NOT_PROVIDED, default_factory=set, doc=None, ), 'articles_dict_attr': RelationshipInfo( attribute_type=AttributeType.RELATIONSHIP, attribute=User.articles_dict_attr, nullable=False, # "no" when `uselist` readable=True, writable=True, value_type=Dict[Any, Article], # guessed the type! target_model=Article, uselist=True, collection_class=User.articles_dict_attr.property. collection_class, # some weird class default=NOT_PROVIDED, default_factory=dict, doc=None, ), 'articles_dict_keyfun': RelationshipInfo( attribute_type=AttributeType.RELATIONSHIP, attribute=User.articles_dict_keyfun, nullable=False, # "no" when `uselist` readable=True, writable=True, value_type=Dict[Any, Article], # guessed the type! target_model=Article, uselist=True, collection_class=User.articles_dict_keyfun.property. collection_class, # some weird callable default=NOT_PROVIDED, default_factory=dict, doc=None, ), 'article_titles': AssociationProxyInfo( attribute_type=AttributeType.ASSOCIATION_PROXY, attribute=User.article_titles, nullable=False, # "yes" when `scalar` readable=True, writable=False, # always false value_type=List[str], # dict: target column's type, target model target_model=Article, collection_class=list, default=NOT_PROVIDED, default_factory=list, doc=None, target_attr_info=ColumnInfo(attribute_type=AttributeType.COLUMN, attribute=Article.title, primary_key=False, foreign_key=False, nullable=True, readable=True, writable=True, value_type=str, default=None, default_factory=None, doc=None), ), 'article_authors': AssociationProxyInfo( attribute_type=AttributeType.ASSOCIATION_PROXY, attribute=User.article_authors, nullable=False, readable=True, writable=False, # always false value_type=List[User], # dict: target column's type, target model target_model=Article, collection_class=list, default=NOT_PROVIDED, default_factory=list, doc=None, target_attr_info=RelationshipInfo( attribute_type=AttributeType.RELATIONSHIP, attribute=Article.user, nullable=True, readable=True, writable=True, value_type=User, target_model=User, uselist=False, collection_class=None, default=None, default_factory=None, doc=None, ), ), 'articles_q': DynamicLoaderInfo( attribute_type=AttributeType.DYNAMIC_LOADER, attribute=User.articles_q, nullable=False, # "no" when `uselist` readable=True, writable=True, # yes it is! value_type=List[Article], # target model, collection target_model=Article, uselist=True, collection_class=list, default=NOT_PROVIDED, default_factory=list, doc=None, ), } # Compare keys first assert set(generated_fields) == set(expected_fields) # Compare values if False: assert generated_fields == expected_fields # Compare values one by one # May be easier to debug when the difference is too large else: for k in generated_fields: assert (k, generated_fields[k]) == (k, expected_fields[k]) # Compare final values assert { name: attr.final_value_type for name, attr in generated_fields.items() } == { '_ignored': Optional[str], 'annotated_int': str, 'int': Optional[int], 'enum': Optional[EnumType], 'optional': Optional[str], 'required': str, 'default': str, 'documented': Optional[str], 'json_attr': Optional[dict], 'property_without_type': Any, # note: `Any` is not wrapped into Optional[] 'property_typed': str, 'property_documented': Any, # note: `Any` is not wrapped into Optional[] 'property_nullable': Optional[str], 'property_writable': str, 'hybrid_property_typed': str, 'hybrid_property_writable': str, 'hybrid_method_attr': Any, 'expression': Optional[int], 'point': Point, 'synonym': Point, 'articles_list': List[Article], 'articles_set': Set[Article], 'articles_dict_attr': Dict[Any, Article], 'articles_dict_keyfun': Dict[Any, Article], 'article_titles': List[str], 'article_authors': List[User], 'articles_q': List[Article], } # Test sa_attribute_info() for attribute_name, expected_attribute_info in expected_fields.items(): assert sa_attribute_info(User, attribute_name) == expected_attribute_info # Test primary key assert sa_model_primary_key_info(User) == { 'annotated_int': expected_fields['annotated_int'] } # Test sa_model_attributes_by_type() attrs_by_type = sa_model_attributes_by_type(User) assert set(attrs_by_type) == { type(attr_info) for attr_info in expected_fields.values() } assert attrs_by_type == { AttributeInfoType: { attr_name: attr_info for attr_name, attr_info in expected_fields.items() if type(attr_info) == AttributeInfoType # don't use isinstance() because DynamicLoader will be part of relationship then } for AttributeInfoType in set(attrs_by_type) }
def test_sa_model_info_arguments(): """ Test sa_model_info() targeting arguments """ assert set(sa_model_info(User, types=AttributeType.COLUMN)) == { '_ignored', 'annotated_int', 'int', 'enum', 'optional', 'required', 'default', 'documented', 'json_attr', } assert set( sa_model_info(User, types=AttributeType.COLUMN, exclude=('int', 'enum', 'json_attr'))) == { '_ignored', 'annotated_int', 'optional', 'required', 'default', 'documented', } assert set(sa_model_info(User, types=AttributeType.PROPERTY_R)) == { # only readable 'property_without_type', 'property_typed', 'property_documented', 'property_nullable', 'property_writable', # both readable and writable } assert set(sa_model_info(User, types=AttributeType.PROPERTY_W)) == { # only writable 'property_writable', # fine selection } assert set(sa_model_info(User, types=AttributeType.PROPERTY_RW)) == { # both readable and writable 'property_without_type', 'property_typed', 'property_documented', 'property_nullable', 'property_writable', 'property_writable', } assert set(sa_model_info(User, types=AttributeType.HYBRID_PROPERTY_R)) == { 'hybrid_property_typed', 'hybrid_property_writable', } assert set(sa_model_info(User, types=AttributeType.HYBRID_PROPERTY_W)) == { 'hybrid_property_writable', } assert set(sa_model_info(User, types=AttributeType.HYBRID_PROPERTY_RW)) == { 'hybrid_property_typed', 'hybrid_property_writable', } assert set(sa_model_info(User, types=AttributeType.RELATIONSHIP)) == { 'articles_list', 'articles_set', 'articles_dict_attr', 'articles_dict_keyfun', } assert set(sa_model_info(User, types=AttributeType.DYNAMIC_LOADER)) == { 'articles_q', } assert set(sa_model_info(User, types=AttributeType.ASSOCIATION_PROXY)) == { 'article_titles', 'article_authors', } assert set(sa_model_info(User, types=AttributeType.COMPOSITE)) == { 'point', 'synonym', } assert set(sa_model_info(User, types=AttributeType.EXPRESSION)) == { 'expression', } assert set(sa_model_info(User, types=AttributeType.HYBRID_METHOD)) == { 'hybrid_method_attr', }
def test_sa_model_info_extractin__Number(): """ Test sa_model_info(Number): test for defaults """ common_field_info = dict(attribute_type=AttributeType.COLUMN, primary_key=False, foreign_key=False, readable=True, writable=True, value_type=int, doc=None) generated_fields = sa_model_info(Number, types=AttributeType.ALL, exclude=()) expected_fields = { 'id': ColumnInfo( **{ **common_field_info, **dict( attribute=Number.id, primary_key=True, nullable=False, # primary key default=NOT_PROVIDED, # because not nullable default_factory=None, ), }), 'n': ColumnInfo( attribute=Number.n, nullable=True, # nullable default=None, # because nullable default_factory=None, **common_field_info), 'nd1': ColumnInfo(attribute=Number.nd1, nullable=True, default=100, default_factory=None, **common_field_info), 'nd2': ColumnInfo( attribute=Number.nd2, nullable=True, default=NOT_PROVIDED, # we don't work with callables default_factory=None, **common_field_info), 'nd3': ColumnInfo( attribute=Number.nd3, nullable=True, default=NOT_PROVIDED, # we don't work with expressions default_factory=None, **common_field_info), 'd1': ColumnInfo(attribute=Number.d1, nullable=False, default=100, default_factory=None, **common_field_info), 'd2': ColumnInfo( attribute=Number.d2, nullable=False, default=NOT_PROVIDED, # we don't work with callables default_factory=None, **common_field_info), 'd3': ColumnInfo( attribute=Number.d3, nullable=False, default=NOT_PROVIDED, # we don't work with expressions default_factory=None, **common_field_info), } assert generated_fields == expected_fields # Test primary key assert sa_model_primary_key_info(Number) == {'id': expected_fields['id']}
def test_sa_model_info_extractin__Article(): """ Test sa_model_info(Article) """ generated_fields = sa_model_info(Article, types=AttributeType.ALL, exclude=()) expected_fields = { 'id': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=Article.id, primary_key=True, foreign_key=False, nullable=False, # primary key readable=True, writable=True, value_type=int, default=NOT_PROVIDED, # because not nullable default_factory=None, doc=None), 'user_id': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=Article.user_id, primary_key=False, foreign_key=True, nullable=True, readable=True, writable=True, value_type=str, # Note: gotten through a ForeinKey() default=None, # because nullable default_factory=None, doc=None), 'title': ColumnInfo( attribute_type=AttributeType.COLUMN, attribute=Article.title, primary_key=False, foreign_key=False, nullable=True, readable=True, writable=True, value_type=str, default=None, # because nullable default_factory=None, doc=None), 'user': RelationshipInfo( attribute_type=AttributeType.RELATIONSHIP, attribute=Article.user, nullable=True, # singular readable=True, writable=True, value_type=User, # not wrapped in any collections target_model=User, uselist=False, collection_class=None, default=None, # because nullable default_factory=None, doc=None), } assert generated_fields == expected_fields # Compare final values assert { name: attr.final_value_type for name, attr in generated_fields.items() } == { 'id': int, 'user_id': Optional[str], 'title': Optional[str], 'user': Optional[User], } # Test primary key assert sa_model_primary_key_info(Article) == {'id': expected_fields['id']}