http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/b6193359/aria/storage/modeling/model.py ---------------------------------------------------------------------- diff --git a/aria/storage/modeling/model.py b/aria/storage/modeling/model.py new file mode 100644 index 0000000..62b90b3 --- /dev/null +++ b/aria/storage/modeling/model.py @@ -0,0 +1,219 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from sqlalchemy.ext.declarative import declarative_base + +from . import ( + template_elements, + instance_elements, + orchestrator_elements, + elements, + structure, +) + +__all__ = ( + 'aria_declarative_base', + + 'Parameter', + + 'MappingTemplate', + 'InterfaceTemplate', + 'OperationTemplate', + 'ServiceTemplate', + 'NodeTemplate', + 'GroupTemplate', + 'ArtifactTemplate', + 'PolicyTemplate', + 'GroupPolicyTemplate', + 'GroupPolicyTriggerTemplate', + 'RequirementTemplate', + 'CapabilityTemplate', + + 'Mapping', + 'Substitution', + 'ServiceInstance', + 'Node', + 'Relationship', + 'Artifact', + 'Group', + 'Interface', + 'Operation', + 'Capability', + 'Policy', + 'GroupPolicy', + 'GroupPolicyTrigger', + + 'Execution', + 'ServiceInstanceUpdate', + 'ServiceInstanceUpdateStep', + 'ServiceInstanceModification', + 'Plugin', + 'Task' +) + +aria_declarative_base = declarative_base(cls=structure.ModelIDMixin) + +# pylint: disable=abstract-method + +# region elements + + +class Parameter(aria_declarative_base, elements.ParameterBase): + pass + +# endregion + +# region template models + + +class MappingTemplate(aria_declarative_base, template_elements.MappingTemplateBase): + pass + + +class SubstitutionTemplate(aria_declarative_base, template_elements.SubstitutionTemplateBase): + pass + + +class InterfaceTemplate(aria_declarative_base, template_elements.InterfaceTemplateBase): + pass + + +class OperationTemplate(aria_declarative_base, template_elements.OperationTemplateBase): + pass + + +class ServiceTemplate(aria_declarative_base, template_elements.ServiceTemplateBase): + pass + + +class NodeTemplate(aria_declarative_base, template_elements.NodeTemplateBase): + pass + + +class GroupTemplate(aria_declarative_base, template_elements.GroupTemplateBase): + pass + + +class ArtifactTemplate(aria_declarative_base, template_elements.ArtifactTemplateBase): + pass + + +class PolicyTemplate(aria_declarative_base, template_elements.PolicyTemplateBase): + pass + + +class GroupPolicyTemplate(aria_declarative_base, template_elements.GroupPolicyTemplateBase): + pass + + +class GroupPolicyTriggerTemplate(aria_declarative_base, + template_elements.GroupPolicyTriggerTemplateBase): + pass + + +class RequirementTemplate(aria_declarative_base, template_elements.RequirementTemplateBase): + pass + + +class CapabilityTemplate(aria_declarative_base, template_elements.CapabilityTemplateBase): + pass + + +# endregion + +# region instance models + +class Mapping(aria_declarative_base, instance_elements.MappingBase): + pass + + +class Substitution(aria_declarative_base, instance_elements.SubstitutionBase): + pass + + +class ServiceInstance(aria_declarative_base, instance_elements.ServiceInstanceBase): + pass + + +class Node(aria_declarative_base, instance_elements.NodeBase): + pass + + +class Relationship(aria_declarative_base, instance_elements.RelationshipBase): + pass + + +class Artifact(aria_declarative_base, instance_elements.ArtifactBase): + pass + + +class Group(aria_declarative_base, instance_elements.GroupBase): + pass + + +class Interface(aria_declarative_base, instance_elements.InterfaceBase): + pass + + +class Operation(aria_declarative_base, instance_elements.OperationBase): + pass + + +class Capability(aria_declarative_base, instance_elements.CapabilityBase): + pass + + +class Policy(aria_declarative_base, instance_elements.PolicyBase): + pass + + +class GroupPolicy(aria_declarative_base, instance_elements.GroupPolicyBase): + pass + + +class GroupPolicyTrigger(aria_declarative_base, instance_elements.GroupPolicyTriggerBase): + pass + + +# endregion + +# region orchestrator models + +class Execution(aria_declarative_base, orchestrator_elements.Execution): + pass + + +class ServiceInstanceUpdate(aria_declarative_base, + orchestrator_elements.ServiceInstanceUpdateBase): + pass + + +class ServiceInstanceUpdateStep(aria_declarative_base, + orchestrator_elements.ServiceInstanceUpdateStepBase): + pass + + +class ServiceInstanceModification(aria_declarative_base, + orchestrator_elements.ServiceInstanceModificationBase): + pass + + +class Plugin(aria_declarative_base, orchestrator_elements.PluginBase): + pass + + +class Task(aria_declarative_base, orchestrator_elements.TaskBase): + pass +# endregion
http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/b6193359/aria/storage/modeling/orchestrator_elements.py ---------------------------------------------------------------------- diff --git a/aria/storage/modeling/orchestrator_elements.py b/aria/storage/modeling/orchestrator_elements.py new file mode 100644 index 0000000..5f7a3f2 --- /dev/null +++ b/aria/storage/modeling/orchestrator_elements.py @@ -0,0 +1,468 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Aria's storage.models module +Path: aria.storage.models + +models module holds aria's models. + +classes: + * Field - represents a single field. + * IterField - represents an iterable field. + * Model - abstract model implementation. + * Snapshot - snapshots implementation model. + * Deployment - deployment implementation model. + * DeploymentUpdateStep - deployment update step implementation model. + * DeploymentUpdate - deployment update implementation model. + * DeploymentModification - deployment modification implementation model. + * Execution - execution implementation model. + * Node - node implementation model. + * Relationship - relationship implementation model. + * NodeInstance - node instance implementation model. + * RelationshipInstance - relationship instance implementation model. + * Plugin - plugin implementation model. +""" +from collections import namedtuple +from datetime import datetime + +from sqlalchemy import ( + Column, + Integer, + Text, + DateTime, + Boolean, + Enum, + String, + Float, + orm, +) +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.declarative import declared_attr + +from aria.orchestrator.exceptions import TaskAbortException, TaskRetryException + +from .type import List, Dict +from .structure import ModelMixin + +__all__ = ( + 'ServiceInstanceUpdateStepBase', + 'ServiceInstanceUpdateBase', + 'ServiceInstanceModificationBase', + 'Execution', + 'PluginBase', + 'TaskBase' +) + +# pylint: disable=no-self-argument, no-member, abstract-method + + +class Execution(ModelMixin): + """ + Execution model representation. + """ + # Needed only for pylint. the id will be populated by sqlalcehmy and the proper column. + __tablename__ = 'execution' + + __private_fields__ = ['service_instance_fk'] + + TERMINATED = 'terminated' + FAILED = 'failed' + CANCELLED = 'cancelled' + PENDING = 'pending' + STARTED = 'started' + CANCELLING = 'cancelling' + FORCE_CANCELLING = 'force_cancelling' + + STATES = [TERMINATED, FAILED, CANCELLED, PENDING, STARTED, CANCELLING, FORCE_CANCELLING] + END_STATES = [TERMINATED, FAILED, CANCELLED] + ACTIVE_STATES = [state for state in STATES if state not in END_STATES] + + VALID_TRANSITIONS = { + PENDING: [STARTED, CANCELLED], + STARTED: END_STATES + [CANCELLING], + CANCELLING: END_STATES + [FORCE_CANCELLING] + } + + @orm.validates('status') + def validate_status(self, key, value): + """Validation function that verifies execution status transitions are OK""" + try: + current_status = getattr(self, key) + except AttributeError: + return + valid_transitions = self.VALID_TRANSITIONS.get(current_status, []) + if all([current_status is not None, + current_status != value, + value not in valid_transitions]): + raise ValueError('Cannot change execution status from {current} to {new}'.format( + current=current_status, + new=value)) + return value + + created_at = Column(DateTime, index=True) + started_at = Column(DateTime, nullable=True, index=True) + ended_at = Column(DateTime, nullable=True, index=True) + error = Column(Text, nullable=True) + is_system_workflow = Column(Boolean, nullable=False, default=False) + parameters = Column(Dict) + status = Column(Enum(*STATES, name='execution_status'), default=PENDING) + workflow_name = Column(Text) + + @declared_attr + def service_template(cls): + return association_proxy('service_instance', 'service_template') + + @declared_attr + def service_instance_fk(cls): + return cls.foreign_key('service_instance') + + @declared_attr + def service_instance(cls): + return cls.many_to_one_relationship('service_instance') + + @declared_attr + def service_instance_name(cls): + return association_proxy('service_instance', cls.name_column_name()) + + @declared_attr + def service_template_name(cls): + return association_proxy('service_instance', 'service_template_name') + + def __str__(self): + return '<{0} id=`{1}` (status={2})>'.format( + self.__class__.__name__, + getattr(self, self.name_column_name()), + self.status + ) + + +class ServiceInstanceUpdateBase(ModelMixin): + """ + Deployment update model representation. + """ + # Needed only for pylint. the id will be populated by sqlalcehmy and the proper column. + steps = None + + __tablename__ = 'service_instance_update' + __private_fields__ = ['service_instance_fk', + 'execution_fk'] + + _private_fields = ['execution_fk', 'deployment_fk'] + + created_at = Column(DateTime, nullable=False, index=True) + service_instance_plan = Column(Dict, nullable=False) + service_instance_update_node_instances = Column(Dict) + service_instance_update_service_instance = Column(Dict) + service_instance_update_nodes = Column(List) + modified_entity_ids = Column(Dict) + state = Column(Text) + + @declared_attr + def execution_fk(cls): + return cls.foreign_key('execution', nullable=True) + + @declared_attr + def execution(cls): + return cls.many_to_one_relationship('execution') + + @declared_attr + def execution_name(cls): + return association_proxy('execution', cls.name_column_name()) + + @declared_attr + def service_instance_fk(cls): + return cls.foreign_key('service_instance') + + @declared_attr + def service_instance(cls): + return cls.many_to_one_relationship('service_instance') + + @declared_attr + def service_instance_name(cls): + return association_proxy('service_instance', cls.name_column_name()) + + def to_dict(self, suppress_error=False, **kwargs): + dep_update_dict = super(ServiceInstanceUpdateBase, self).to_dict(suppress_error) #pylint: disable=no-member + # Taking care of the fact the DeploymentSteps are _BaseModels + dep_update_dict['steps'] = [step.to_dict() for step in self.steps] + return dep_update_dict + + +class ServiceInstanceUpdateStepBase(ModelMixin): + """ + Deployment update step model representation. + """ + # Needed only for pylint. the id will be populated by sqlalcehmy and the proper column. + __tablename__ = 'service_instance_update_step' + __private_fields__ = ['service_instance_update_fk'] + + _action_types = namedtuple('ACTION_TYPES', 'ADD, REMOVE, MODIFY') + ACTION_TYPES = _action_types(ADD='add', REMOVE='remove', MODIFY='modify') + _entity_types = namedtuple( + 'ENTITY_TYPES', + 'NODE, RELATIONSHIP, PROPERTY, OPERATION, WORKFLOW, OUTPUT, DESCRIPTION, GROUP, ' + 'POLICY_TYPE, POLICY_TRIGGER, PLUGIN') + ENTITY_TYPES = _entity_types( + NODE='node', + RELATIONSHIP='relationship', + PROPERTY='property', + OPERATION='operation', + WORKFLOW='workflow', + OUTPUT='output', + DESCRIPTION='description', + GROUP='group', + POLICY_TYPE='policy_type', + POLICY_TRIGGER='policy_trigger', + PLUGIN='plugin' + ) + + action = Column(Enum(*ACTION_TYPES, name='action_type'), nullable=False) + entity_id = Column(Text, nullable=False) + entity_type = Column(Enum(*ENTITY_TYPES, name='entity_type'), nullable=False) + + @declared_attr + def service_instance_update_fk(cls): + return cls.foreign_key('service_instance_update') + + @declared_attr + def service_instance_update(cls): + return cls.many_to_one_relationship('service_instance_update', + backreference='steps') + + @declared_attr + def deployment_update_name(cls): + return association_proxy('deployment_update', cls.name_column_name()) + + def __hash__(self): + return hash((getattr(self, self.id_column_name()), self.entity_id)) + + def __lt__(self, other): + """ + the order is 'remove' < 'modify' < 'add' + :param other: + :return: + """ + if not isinstance(other, self.__class__): + return not self >= other + + if self.action != other.action: + if self.action == 'remove': + return_value = True + elif self.action == 'add': + return_value = False + else: + return_value = other.action == 'add' + return return_value + + if self.action == 'add': + return self.entity_type == 'node' and other.entity_type == 'relationship' + if self.action == 'remove': + return self.entity_type == 'relationship' and other.entity_type == 'node' + return False + + +class ServiceInstanceModificationBase(ModelMixin): + """ + Deployment modification model representation. + """ + __tablename__ = 'service_instance_modification' + __private_fields__ = ['service_instance_fk'] + + STARTED = 'started' + FINISHED = 'finished' + ROLLEDBACK = 'rolledback' + + STATES = [STARTED, FINISHED, ROLLEDBACK] + END_STATES = [FINISHED, ROLLEDBACK] + + context = Column(Dict) + created_at = Column(DateTime, nullable=False, index=True) + ended_at = Column(DateTime, index=True) + modified_nodes = Column(Dict) + node_instances = Column(Dict) + status = Column(Enum(*STATES, name='deployment_modification_status')) + + @declared_attr + def service_instance_fk(cls): + return cls.foreign_key('service_instance') + + @declared_attr + def service_instance(cls): + return cls.many_to_one_relationship('service_instance', + backreference='modifications') + + @declared_attr + def service_instance_name(cls): + return association_proxy('service_instance', cls.name_column_name()) + + +class PluginBase(ModelMixin): + """ + Plugin model representation. + """ + __tablename__ = 'plugin' + + archive_name = Column(Text, nullable=False, index=True) + distribution = Column(Text) + distribution_release = Column(Text) + distribution_version = Column(Text) + package_name = Column(Text, nullable=False, index=True) + package_source = Column(Text) + package_version = Column(Text) + supported_platform = Column(Text) + supported_py_versions = Column(List) + uploaded_at = Column(DateTime, nullable=False, index=True) + wheels = Column(List, nullable=False) + + +class TaskBase(ModelMixin): + """ + A Model which represents an task + """ + __tablename__ = 'task' + __private_fields__ = ['node_fk', + 'relationship_fk', + 'execution_fk', + 'plugin_fk'] + + @declared_attr + def node_fk(cls): + return cls.foreign_key('node', nullable=True) + + @declared_attr + def node_name(cls): + return association_proxy('node', cls.name_column_name()) + + @declared_attr + def node(cls): + return cls.many_to_one_relationship('node') + + @declared_attr + def relationship_fk(cls): + return cls.foreign_key('relationship', nullable=True) + + @declared_attr + def relationship_name(cls): + return association_proxy('relationships', cls.name_column_name()) + + @declared_attr + def relationship(cls): + return cls.many_to_one_relationship('relationship') + + @declared_attr + def plugin_fk(cls): + return cls.foreign_key('plugin', nullable=True) + + @declared_attr + def plugin(cls): + return cls.many_to_one_relationship('plugin') + + @declared_attr + def execution_fk(cls): + return cls.foreign_key('execution', nullable=True) + + @declared_attr + def execution(cls): + return cls.many_to_one_relationship('execution') + + @declared_attr + def execution_name(cls): + return association_proxy('execution', cls.name_column_name()) + + PENDING = 'pending' + RETRYING = 'retrying' + SENT = 'sent' + STARTED = 'started' + SUCCESS = 'success' + FAILED = 'failed' + STATES = ( + PENDING, + RETRYING, + SENT, + STARTED, + SUCCESS, + FAILED, + ) + + WAIT_STATES = [PENDING, RETRYING] + END_STATES = [SUCCESS, FAILED] + + RUNS_ON_SOURCE = 'source' + RUNS_ON_TARGET = 'target' + RUNS_ON_NODE_INSTANCE = 'node_instance' + RUNS_ON = (RUNS_ON_NODE_INSTANCE, RUNS_ON_SOURCE, RUNS_ON_TARGET) + + @orm.validates('max_attempts') + def validate_max_attempts(self, _, value): # pylint: disable=no-self-use + """Validates that max attempts is either -1 or a positive number""" + if value < 1 and value != TaskBase.INFINITE_RETRIES: + raise ValueError('Max attempts can be either -1 (infinite) or any positive number. ' + 'Got {value}'.format(value=value)) + return value + + INFINITE_RETRIES = -1 + + status = Column(Enum(*STATES, name='status'), default=PENDING) + + due_at = Column(DateTime, default=datetime.utcnow) + started_at = Column(DateTime, default=None) + ended_at = Column(DateTime, default=None) + max_attempts = Column(Integer, default=1) + retry_count = Column(Integer, default=0) + retry_interval = Column(Float, default=0) + ignore_failure = Column(Boolean, default=False) + + # Operation specific fields + implementation = Column(String) + inputs = Column(Dict) + # This is unrelated to the plugin of the task. This field is related to the plugin name + # received from the blueprint. + plugin_name = Column(String) + _runs_on = Column(Enum(*RUNS_ON, name='runs_on'), name='runs_on') + + @property + def runs_on(self): + if self._runs_on == self.RUNS_ON_NODE_INSTANCE: + return self.node + elif self._runs_on == self.RUNS_ON_SOURCE: + return self.relationship.source_node # pylint: disable=no-member + elif self._runs_on == self.RUNS_ON_TARGET: + return self.relationship.target_node # pylint: disable=no-member + return None + + @property + def actor(self): + """ + Return the actor of the task + :return: + """ + return self.node or self.relationship + + @classmethod + def as_node_instance(cls, instance, runs_on, **kwargs): + return cls(node=instance, _runs_on=runs_on, **kwargs) + + @classmethod + def as_relationship_instance(cls, instance, runs_on, **kwargs): + return cls(relationship=instance, _runs_on=runs_on, **kwargs) + + @staticmethod + def abort(message=None): + raise TaskAbortException(message) + + @staticmethod + def retry(message=None, retry_interval=None): + raise TaskRetryException(message, retry_interval=retry_interval) http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/b6193359/aria/storage/modeling/structure.py ---------------------------------------------------------------------- diff --git a/aria/storage/modeling/structure.py b/aria/storage/modeling/structure.py new file mode 100644 index 0000000..eacdb44 --- /dev/null +++ b/aria/storage/modeling/structure.py @@ -0,0 +1,320 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Aria's storage.structures module +Path: aria.storage.structures + +models module holds aria's models. + +classes: + * Field - represents a single field. + * IterField - represents an iterable field. + * PointerField - represents a single pointer field. + * IterPointerField - represents an iterable pointers field. + * Model - abstract model implementation. +""" + +from sqlalchemy.orm import relationship, backref +from sqlalchemy.ext import associationproxy +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + Text, + Table, +) + +from . import utils + + +class Function(object): + """ + An intrinsic function. + + Serves as a placeholder for a value that should eventually be derived + by calling the function. + """ + + @property + def as_raw(self): + raise NotImplementedError + + def _evaluate(self, context, container): + raise NotImplementedError + + def __deepcopy__(self, memo): + # Circumvent cloning in order to maintain our state + return self + + +class ElementBase(object): + """ + Base class for :class:`ServiceInstance` elements. + + All elements support validation, diagnostic dumping, and representation as + raw data (which can be translated into JSON or YAML) via :code:`as_raw`. + """ + + @property + def as_raw(self): + raise NotImplementedError + + def validate(self, context): + pass + + def coerce_values(self, context, container, report_issues): + pass + + def dump(self, context): + pass + + +class ModelElementBase(ElementBase): + """ + Base class for :class:`ServiceModel` elements. + + All model elements can be instantiated into :class:`ServiceInstance` elements. + """ + + def instantiate(self, context, container): + raise NotImplementedError + + +class ModelMixin(ModelElementBase): + + @utils.classproperty + def __modelname__(cls): # pylint: disable=no-self-argument + return getattr(cls, '__mapiname__', cls.__tablename__) + + @classmethod + def id_column_name(cls): + raise NotImplementedError + + @classmethod + def name_column_name(cls): + raise NotImplementedError + + @classmethod + def _get_cls_by_tablename(cls, tablename): + """Return class reference mapped to table. + + :param tablename: String with name of table. + :return: Class reference or None. + """ + if tablename in (cls.__name__, cls.__tablename__): + return cls + + for table_cls in cls._decl_class_registry.values(): + if tablename == getattr(table_cls, '__tablename__', None): + return table_cls + + @classmethod + def foreign_key(cls, table_name, nullable=False): + """Return a ForeignKey object with the relevant + + :param table: Unique id column in the parent table + :param nullable: Should the column be allowed to remain empty + """ + return Column(Integer, + ForeignKey('{tablename}.id'.format(tablename=table_name), ondelete='CASCADE'), + nullable=nullable) + + @classmethod + def one_to_one_relationship(cls, table_name, backreference=None): + return relationship(lambda: cls._get_cls_by_tablename(table_name), + backref=backref(backreference or cls.__tablename__, uselist=False)) + + @classmethod + def many_to_one_relationship(cls, + parent_table_name, + foreign_key_column=None, + backreference=None, + backref_kwargs=None, + **kwargs): + """Return a one-to-many SQL relationship object + Meant to be used from inside the *child* object + + :param parent_class: Class of the parent table + :param cls: Class of the child table + :param foreign_key_column: The column of the foreign key (from the child table) + :param backreference: The name to give to the reference to the child (on the parent table) + """ + relationship_kwargs = kwargs + if foreign_key_column: + relationship_kwargs.setdefault('foreign_keys', getattr(cls, foreign_key_column)) + + backref_kwargs = backref_kwargs or {} + backref_kwargs.setdefault('lazy', 'dynamic') + # The following line make sure that when the *parent* is + # deleted, all its connected children are deleted as well + backref_kwargs.setdefault('cascade', 'all') + + return relationship(lambda: cls._get_cls_by_tablename(parent_table_name), + backref=backref(backreference or utils.pluralize(cls.__tablename__), + **backref_kwargs or {}), + **relationship_kwargs) + + @classmethod + def relationship_to_self(cls, local_column): + + remote_side_str = '{cls.__name__}.{remote_column}'.format( + cls=cls, + remote_column=cls.id_column_name() + ) + primaryjoin_str = '{remote_side_str} == {cls.__name__}.{local_column}'.format( + remote_side_str=remote_side_str, + cls=cls, + local_column=local_column) + return relationship(cls._get_cls_by_tablename(cls.__tablename__).__name__, + primaryjoin=primaryjoin_str, + remote_side=remote_side_str, + post_update=True) + + @classmethod + def many_to_many_relationship(cls, other_table_name, table_prefix, relationship_kwargs=None): + """Return a many-to-many SQL relationship object + + Notes: + 1. The backreference name is the current table's table name + 2. This method creates a new helper table in the DB + + :param cls: The class of the table we're connecting from + :param other_table_name: The class of the table we're connecting to + :param table_prefix: Custom prefix for the helper table name and the + backreference name + """ + current_table_name = cls.__tablename__ + current_column_name = '{0}_id'.format(current_table_name) + current_foreign_key = '{0}.id'.format(current_table_name) + + other_column_name = '{0}_id'.format(other_table_name) + other_foreign_key = '{0}.id'.format(other_table_name) + + helper_table_name = '{0}_{1}'.format(current_table_name, other_table_name) + + backref_name = current_table_name + if table_prefix: + helper_table_name = '{0}_{1}'.format(table_prefix, helper_table_name) + backref_name = '{0}_{1}'.format(table_prefix, backref_name) + + secondary_table = cls.get_secondary_table( + cls.metadata, + helper_table_name, + current_column_name, + other_column_name, + current_foreign_key, + other_foreign_key + ) + + return relationship( + lambda: cls._get_cls_by_tablename(other_table_name), + secondary=secondary_table, + backref=backref(backref_name), + **(relationship_kwargs or {}) + ) + + @staticmethod + def get_secondary_table(metadata, + helper_table_name, + first_column_name, + second_column_name, + first_foreign_key, + second_foreign_key): + """Create a helper table for a many-to-many relationship + + :param helper_table_name: The name of the table + :param first_column_name: The name of the first column in the table + :param second_column_name: The name of the second column in the table + :param first_foreign_key: The string representing the first foreign key, + for example `blueprint.storage_id`, or `tenants.id` + :param second_foreign_key: The string representing the second foreign key + :return: A Table object + """ + return Table( + helper_table_name, + metadata, + Column( + first_column_name, + Integer, + ForeignKey(first_foreign_key) + ), + Column( + second_column_name, + Integer, + ForeignKey(second_foreign_key) + ) + ) + + def to_dict(self, fields=None, suppress_error=False): + """Return a dict representation of the model + + :param suppress_error: If set to True, sets `None` to attributes that + it's unable to retrieve (e.g., if a relationship wasn't established + yet, and so it's impossible to access a property through it) + """ + res = dict() + fields = fields or self.fields() + for field in fields: + try: + field_value = getattr(self, field) + except AttributeError: + if suppress_error: + field_value = None + else: + raise + if isinstance(field_value, list): + field_value = list(field_value) + elif isinstance(field_value, dict): + field_value = dict(field_value) + elif isinstance(field_value, ModelMixin): + field_value = field_value.to_dict() + res[field] = field_value + + return res + + @classmethod + def _association_proxies(cls): + for col, value in vars(cls).items(): + if isinstance(value, associationproxy.AssociationProxy): + yield col + + @classmethod + def fields(cls): + """Return the list of field names for this table + + Mostly for backwards compatibility in the code (that uses `fields`) + """ + fields = set(cls._association_proxies()) + fields.update(cls.__table__.columns.keys()) + return fields - set(getattr(cls, '__private_fields__', [])) + + def __repr__(self): + return '<{__class__.__name__} id=`{id}`>'.format( + __class__=self.__class__, + id=getattr(self, self.name_column_name())) + + +class ModelIDMixin(object): + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(Text, nullable=True, index=True) + + @classmethod + def id_column_name(cls): + return 'id' + + @classmethod + def name_column_name(cls): + return 'name'