def construct(*, schema: types.Schema) -> TableArgs: """ Construct any table args from the object schema. Look for x-composite-unique and x-composite-index keys in the schema and construct any unique constraints and indexes based on their value. Args: schema: The schema for the object. Returns: A tuple with any unique constraints and indexes. """ # Keep track of any table arguments table_args: typing.List[typing.Iterable[TableArg]] = [] # Handle x-composite-unique unique_spec = helpers.get_ext_prop(source=schema, name="x-composite-unique") if unique_spec is not None: table_args.append(factory.unique_factory(spec=unique_spec)) # Handle x-composite-index index_spec = helpers.get_ext_prop(source=schema, name="x-composite-index") if index_spec is not None: table_args.append(factory.index_factory(spec=index_spec)) return tuple(itertools.chain.from_iterable(table_args))
def test_invalid(name, value): """ GIVEN property and invalid value WHEN get_ext_prop is called with a source made of the property and value THEN MalformedExtensionPropertyError is raised. """ source = {name: value} with pytest.raises(exceptions.MalformedExtensionPropertyError): helpers.get_ext_prop(source=source, name=name)
def test_composite_index_invalid(value): """ GIVEN value for x-composite-index that has an invalid format WHEN get_ext_prop with x-composite-index and the value THEN MalformedExtensionPropertyError is raised. """ name = "x-composite-index" source = {name: value} with pytest.raises(exceptions.MalformedExtensionPropertyError): helpers.get_ext_prop(source=source, name=name)
def _handle_object_reference(*, spec: types.Schema, schemas: types.Schemas) -> types.Schema: """ Determine the foreign key schema for an object reference. Args: spec: The schema of the object reference. schemas: All defined schemas. Returns: The foreign key schema. """ tablename = helpers.get_ext_prop(source=spec, name="x-tablename") if not tablename: raise exceptions.MalformedSchemaError( "Referenced object is missing x-tablename property.") properties = spec.get("properties") if properties is None: raise exceptions.MalformedSchemaError( "Referenced object does not have any properties.") logical_name = "id" id_spec = properties.get(logical_name) if id_spec is None: raise exceptions.MalformedSchemaError( "Referenced object does not have id property.") # Preparing specification prepared_id_spec = helpers.prepare_schema(schema=id_spec, schemas=schemas) id_type = prepared_id_spec.get("type") if id_type is None: raise exceptions.MalformedSchemaError( "Referenced object id property does not have a type.") return {"type": id_type, "x-foreign-key": f"{tablename}.id"}
def test_miss(): """ GIVEN empty source WHEN get_ext_prop is called with the source THEN None is returned. """ assert helpers.get_ext_prop(source={}, name="missing") is None
def test_valid(name, value): """ GIVEN property and valid value WHEN get_ext_prop is called with a source made of the property and value THEN the value is returned. """ source = {name: value} returned_value = helpers.get_ext_prop(source=source, name=name) assert returned_value == value
def test_miss_default(): """ GIVEN empty source and default value WHEN get_ext_prop is called with the source and default value THEN default value is returned. """ default = "value 1" value = helpers.get_ext_prop(source={}, name="missing", default=default) assert value == default
def test_unique_constraint_valid(value): """ GIVEN value for x-composite-unique that has a valid format WHEN get_ext_prop with x-composite-unique and the value THEN the value is returned. """ name = "x-composite-unique" source = {name: value} returned_value = helpers.get_ext_prop(source=source, name=name) assert returned_value == value
def test_composite_index_valid(value): """ GIVEN value for x-composite-index that has a valid format WHEN get_ext_prop with x-composite-index and the value THEN the value is returned. """ name = "x-composite-index" source = {name: value} returned_value = helpers.get_ext_prop(source=source, name=name) assert returned_value == value
def _spec_to_column(*, spec: types.Schema, required: typing.Optional[bool] = None): """ Convert specification to a SQLAlchemy column. Args: spec: The schema for the column. required: Whether the object property is required. Returns: The SQLAlchemy column based on the schema. """ # Keep track of column arguments args: typing.Tuple[typing.Any, ...] = () kwargs: types.Schema = {} # Calculate column modifiers kwargs["nullable"] = _calculate_nullable(spec=spec, required=required) if helpers.get_ext_prop(source=spec, name="x-primary-key"): kwargs["primary_key"] = True autoincrement = helpers.get_ext_prop(source=spec, name="x-autoincrement") if autoincrement is not None: if autoincrement: kwargs["autoincrement"] = True else: kwargs["autoincrement"] = False if helpers.get_ext_prop(source=spec, name="x-index"): kwargs["index"] = True if helpers.get_ext_prop(source=spec, name="x-unique"): kwargs["unique"] = True foreign_key = helpers.get_ext_prop(source=spec, name="x-foreign-key") if foreign_key: args = (*args, sqlalchemy.ForeignKey(foreign_key)) # Calculating type of column type_ = _determine_type(spec=spec) return sqlalchemy.Column(type_, *args, **kwargs)
def test_pop(): """ GIVEN property and valid value WHEN get_ext_property is called with the name, value and pop set THEN the key is removed from the dictionary. """ name = "x-dict-ignore" value = True source = {name: value} returned_value = helpers.get_ext_prop(source=source, name=name, pop=True) assert returned_value == value assert source == {}
def handle_object_reference(*, spec: types.Schema, schemas: types.Schemas, fk_column: str) -> types.Schema: """ Determine the foreign key schema for an object reference. Args: spec: The schema of the object reference. schemas: All defined schemas. fk_column: The foreign column name to use. Returns: The foreign key schema. """ tablename = helpers.get_ext_prop(source=spec, name="x-tablename") if not tablename: raise exceptions.MalformedSchemaError( "Referenced object is missing x-tablename property.") properties = spec.get("properties") if properties is None: raise exceptions.MalformedSchemaError( "Referenced object does not have any properties.") fk_logical_name = fk_column if fk_column is not None else "id" fk_spec = properties.get(fk_logical_name) if fk_spec is None: raise exceptions.MalformedSchemaError( f"Referenced object does not have {fk_logical_name} property.") # Preparing specification prepared_fk_spec = helpers.prepare_schema(schema=fk_spec, schemas=schemas) fk_type = prepared_fk_spec.get("type") if fk_type is None: raise exceptions.MalformedSchemaError( f"Referenced object {fk_logical_name} property does not have a type." ) return {"type": fk_type, "x-foreign-key": f"{tablename}.{fk_logical_name}"}
def _many_to_many_column_artifacts( *, model_schema: types.Schema, schemas: types.Schemas) -> _ManyToManyColumnArtifacts: """ Retrieve column artifacts of a secondary table for a many to many relationship. Args: model_schema: The schema for one side of the many to many relationship. schemas: Used to resolve any $ref. Returns: The artifacts needed to construct a column of the secondary table in a many to many relationship. """ # Resolve $ref and merge allOf model_schema = helpers.prepare_schema(schema=model_schema, schemas=schemas) # Check schema type model_type = model_schema.get("type") if model_type is None: raise exceptions.MalformedSchemaError("Every schema must have a type.") if model_type != "object": raise exceptions.MalformedSchemaError( "A schema that is part of a many to many relationship must be of type " "object.") # Retrieve table name tablename = helpers.get_ext_prop(source=model_schema, name="x-tablename") if tablename is None: raise exceptions.MalformedSchemaError( "A schema that is part of a many to many relationship must set the " "x-tablename property.") # Find primary key properties = model_schema.get("properties") if properties is None: raise exceptions.MalformedSchemaError( "A schema that is part of a many to many relationship must have properties." ) if not properties: raise exceptions.MalformedSchemaError( "A schema that is part of a many to many relationship must have at least 1 " "property.") type_ = None format_ = None for property_name, property_schema in properties.items(): if helpers.peek.primary_key(schema=property_schema, schemas=schemas): if type_ is not None: raise exceptions.MalformedSchemaError( "A schema that is part of a many to many relationship must have " "exactly 1 primary key.") try: type_ = helpers.peek.type_(schema=property_schema, schemas=schemas) except exceptions.TypeMissingError: raise exceptions.MalformedSchemaError( "A schema that is part of a many to many relationship must define " "a type for the primary key.") format_ = helpers.peek.format_(schema=property_schema, schemas=schemas) max_length = helpers.peek.max_length(schema=property_schema, schemas=schemas) column_name = property_name if type_ is None: raise exceptions.MalformedSchemaError( "A schema that is part of a many to many relationship must have " "exactly 1 primary key.") if type_ in {"object", "array"}: raise exceptions.MalformedSchemaError( "A schema that is part of a many to many relationship cannot define it's " "primary key to be of type object nor array.") return _ManyToManyColumnArtifacts(type_, format_, tablename, column_name, max_length)
def handle_array( *, spec: types.Schema, model_schema: types.Schema, schemas: types.Schemas, logical_name: str, ) -> typing.Tuple[typing.List[typing.Tuple[str, typing.Type]], types.Schema]: """ Generate properties for a reference to another object through an array. Assume that when any allOf and $ref are resolved in the root spec the type is array. Args: spec: The schema for the column. model_schema: The schema of the one to many parent. schemas: Used to resolve any $ref. logical_name: The logical name in the specification for the schema. Returns: The logical name and the relationship for the referenced object. """ # Resolve any allOf and $ref spec = helpers.prepare_schema(schema=spec, schemas=schemas) # Get item specification item_spec = spec.get("items") if item_spec is None: raise exceptions.MalformedRelationshipError( "An array property must include items property.") obj_artifacts = object_ref.gather_object_artifacts( spec=item_spec, logical_name=logical_name, schemas=schemas) # Check for uselist if obj_artifacts.uselist is not None: raise exceptions.MalformedRelationshipError( "x-uselist is not supported for one to many relationships.") # Check referenced specification ref_spec = helpers.prepare_schema(schema=obj_artifacts.spec, schemas=schemas) ref_type = ref_spec.get("type") if ref_type != "object": raise exceptions.MalformedRelationshipError( "One to many relationships must reference an object type schema.") ref_tablename = helpers.get_ext_prop(source=ref_spec, name="x-tablename") if ref_tablename is None: raise exceptions.MalformedRelationshipError( "One to many relationships must reference a schema with " "x-tablename defined.") # Construct relationship relationship_return = ( logical_name, sqlalchemy.orm.relationship( obj_artifacts.ref_logical_name, backref=obj_artifacts.backref, secondary=obj_artifacts.secondary, ), ) # Construct entry for the addition for the model schema spec_return = { "type": "array", "items": { "type": "object", "x-de-$ref": obj_artifacts.ref_logical_name }, } # Add foreign key to referenced schema if obj_artifacts.secondary is None: _set_foreign_key( ref_model_name=obj_artifacts.ref_logical_name, model_schema=model_schema, schemas=schemas, fk_column=obj_artifacts.fk_column, ) else: table = _construct_association_table( parent_schema=model_schema, child_schema=obj_artifacts.spec, schemas=schemas, tablename=obj_artifacts.secondary, ) facades.models.set_association(table=table, name=obj_artifacts.secondary) return [relationship_return], spec_return
def _set_foreign_key( *, ref_model_name: str, model_schema: types.Schema, schemas: types.Schemas, fk_column: str, ) -> None: """ Set the foreign key on an existing model or add it to the schemas. Args: ref_model_name: The name of the referenced model. model_schema: The schema of the one to many parent. schemas: All the model schemas. fk_column: The name of the foreign key column. """ # Check that model is in schemas if ref_model_name not in schemas: raise exceptions.MalformedRelationshipError( f"{ref_model_name} referenced in relationship was not found in the " "schemas.") # Calculate foreign key specification fk_spec = object_ref.handle_object_reference(spec=model_schema, schemas=schemas, fk_column=fk_column) # Calculate values for foreign key tablename = helpers.get_ext_prop(source=model_schema, name="x-tablename") fk_logical_name = f"{tablename}_{fk_column}" # Gather referenced schema ref_schema = schemas[ref_model_name] # Any top level $ref must already be resolved ref_schema = helpers.merge_all_of(schema=ref_schema, schemas=schemas) fk_required = object_ref.check_foreign_key_required( fk_spec=fk_spec, fk_logical_name=fk_logical_name, model_schema=ref_schema, schemas=schemas, ) if not fk_required: return # Handle model already constructed ref_model: TOptUtilityBase = facades.models.get_model(name=ref_model_name) if ref_model is not None: # Construct foreign key _, fk_column = column.handle_column(schema=fk_spec) setattr(ref_model, fk_logical_name, fk_column) return # Handle model not constructed schemas[ref_model_name] = { "allOf": [ schemas[ref_model_name], { "type": "object", "properties": { fk_logical_name: { **fk_spec, "x-dict-ignore": True } }, }, ] }
def _handle_object( *, spec: types.Schema, schemas: types.Schemas, required: typing.Optional[bool] = None, logical_name: str, ) -> typing.List[typing.Tuple[str, typing.Union[sqlalchemy.Column, typing.Type]]]: """ Generate properties for a reference to another object. Assume that, when any $ref and allOf are resolved, the schema is an object. Args: spec: The schema for the column. schemas: Used to resolve any $ref. required: Whether the object property is required. logical_name: The logical name in the specification for the schema. Returns: The logical name and the SQLAlchemy column for the foreign key and the logical name and relationship for the reference to the object. """ # Default backref backref = None # Checking for $ref and allOf ref = spec.get("$ref") all_of = spec.get("allOf") if ref is not None: # Handling $ref ref_logical_name, spec = helpers.resolve_ref(name=logical_name, schema=spec, schemas=schemas) backref = helpers.get_ext_prop(source=spec, name="x-backref") elif all_of is not None: # Checking for $ref and x-backref counts ref_count = 0 backref_count = 0 for sub_spec in all_of: if sub_spec.get("$ref") is not None: ref_count += 1 if sub_spec.get("x-backref") is not None: backref_count += 1 if ref_count != 1: raise exceptions.MalformedManyToOneRelationshipError( "Many to One relationships defined with allOf must have exactly one " "$ref in the allOf list.") if backref_count > 1: raise exceptions.MalformedManyToOneRelationshipError( "Many to One relationships may have at most 1 x-backref defined." ) # Handling allOf for sub_spec in all_of: backref = helpers.get_ext_prop(source=sub_spec, name="x-backref") if sub_spec.get("$ref") is not None: ref_logical_name, spec = helpers.resolve_ref(name=logical_name, schema=sub_spec, schemas=schemas) else: raise exceptions.MalformedManyToOneRelationshipError( "Many to One relationships are defined using either $ref or allOf." ) # Resolving allOf spec = helpers.merge_all_of(schema=spec, schemas=schemas) # Handling object foreign_key_spec = _handle_object_reference(spec=spec, schemas=schemas) return_value = _handle_column(logical_name=f"{logical_name}_id", spec=foreign_key_spec, required=required) # Creating relationship return_value.append((logical_name, sqlalchemy.orm.relationship(ref_logical_name, backref=backref))) return return_value
def gather_object_artifacts(*, spec: types.Schema, logical_name: str, schemas: types.Schemas) -> ObjectArtifacts: """ Collect artifacts from a specification for constructing an object reference. Get the prepared specification, reference logical name, back reference and foreign key column name from a raw object specification. Raise MalformedRelationshipError if neither $ref nor $allOf is found. Raise MalformedRelationshipError if uselist is defined but backref is not. Raise MalformedRelationshipError if multiple $ref, x-backref, x-secondary, x-foreign-key-column or x-uselist are found. Args: spec: The schema for the column. schemas: Used to resolve any $ref. logical_name: The logical name in the specification for the schema. Returns: The prepared specification, reference logical name, back reference and foreign key column. """ # Default backref backref = None # Default uselist uselist = None # Default secondary secondary = None # Initial foreign key column fk_column = None # Checking for $ref and allOf ref = spec.get("$ref") all_of = spec.get("allOf") if ref is not None: # Handle $ref ref_logical_name, spec = helpers.resolve_ref(name=logical_name, schema=spec, schemas=schemas) elif all_of is not None: # Checking for $ref, and x-backref and x-foreign-key-column counts _check_object_all_of(all_of_spec=all_of) # Handle allOf for sub_spec in all_of: backref = helpers.get_ext_prop(source=sub_spec, name="x-backref", default=backref) uselist = helpers.get_ext_prop(source=sub_spec, name="x-uselist", default=uselist) secondary = helpers.get_ext_prop(source=sub_spec, name="x-secondary", default=secondary) fk_column = helpers.get_ext_prop(source=sub_spec, name="x-foreign-key-column", default=fk_column) if sub_spec.get("$ref") is not None: ref_logical_name, spec = helpers.resolve_ref(name=logical_name, schema=sub_spec, schemas=schemas) else: raise exceptions.MalformedRelationshipError( "Relationships are defined using either $ref or allOf.") # Resolving allOf spec = helpers.merge_all_of(schema=spec, schemas=schemas) # If backref has not been found look in referenced schema if backref is None: backref = helpers.get_ext_prop(source=spec, name="x-backref") # If uselist has not been found look in referenced schema if uselist is None: uselist = helpers.get_ext_prop(source=spec, name="x-uselist") # If secondary has not been found look in referenced schema if secondary is None: secondary = helpers.get_ext_prop(source=spec, name="x-secondary") # If foreign key column has not been found look in referenced schema if fk_column is None: fk_column = helpers.get_ext_prop(source=spec, name="x-foreign-key-column") # If foreign key column is still None, default to id if fk_column is None: fk_column = "id" # Check if uselist is defined and backref is not if uselist is not None and backref is None: raise exceptions.MalformedRelationshipError( "Relationships with x-uselist defined must also define x-backref.") return ObjectArtifacts(spec, ref_logical_name, backref, fk_column, uselist, secondary)
def check_foreign_key_required( *, fk_spec: types.Schema, fk_logical_name: str, model_schema: types.Schema, schemas: types.Schemas, ) -> bool: """ Check whether a foreign key has already been defined. Assume model_schema has already resolved any $ref and allOf at the object level. They may not have been resolved at the property level. Check whether the proposed logical name is already defined on the model schema. If it has been, check that the type is correct and that the foreign key reference has been defined and points to the correct column. Raise MalformedRelationshipError if a property has already been defined with the same name as is proposed for the foreign key but it has the wrong type or does not define the correct foreign key constraint. Args: fk_spec: The proposed foreign key specification. fk_logical_name: The proposed name for the foreign key property. model_schema: The schema for the model on which the foreign key is proposed to be added. schemas: Used to resolve any $ref at the property level. Returns: Whether defining the foreign key is necessary given the model schema. """ properties = model_schema["properties"] model_fk_spec = properties.get(fk_logical_name) if model_fk_spec is None: return True model_fk_spec = helpers.prepare_schema(schema=model_fk_spec, schemas=schemas) # Check type model_fk_type = model_fk_spec.get("type") if model_fk_type is None: raise exceptions.MalformedRelationshipError( f"{fk_logical_name} does not have a type. ") fk_type = fk_spec["type"] if model_fk_type != fk_type: raise exceptions.MalformedRelationshipError( "The foreign key required for the relationship has a different type than " "the property already defined under that name. " f"The required type is {fk_type}. " f"The {fk_logical_name} property has the {model_fk_type} type.") # Check foreign key constraint model_foreign_key = helpers.get_ext_prop(source=model_fk_spec, name="x-foreign-key") foreign_key = fk_spec["x-foreign-key"] if model_foreign_key is None: raise exceptions.MalformedRelationshipError( f"The property already defined under {fk_logical_name} does not define a " 'foreign key constraint. Use the "x-foreign-key" extension property to ' f'define a foreign key constraint, for example: "{foreign_key}".') if model_foreign_key != foreign_key: raise exceptions.MalformedRelationshipError( "The foreign key required for the relationship has a different foreign " "key constraint than the property already defined under that name. " f"The required constraint is {foreign_key}. " f"The {fk_logical_name} property has the {model_foreign_key} constraint." ) return False