Example #1
0
def build_actions_chain(left_schema: Schema,
                        right_schema: Schema) -> Iterable[BaseAction]:
    """
    Build full Action objects chain which suitable for such schema
    change.
    :param left_schema: current schema
    :param right_schema: schema collected from mongoengine models
    :return: iterable of Action objects
    """
    action_chain = []

    # Actions registry sorted by priority
    registry = list(sorted(actions_registry.values(),
                           key=lambda x: x.priority))

    left_schema = copy(left_schema)
    document_types = get_all_document_types(left_schema, right_schema)
    for action_cls in registry:
        if issubclass(action_cls, BaseDocumentAction):
            new_actions = list(
                build_document_action_chain(action_cls, left_schema,
                                            right_schema, document_types))
        elif issubclass(action_cls, BaseFieldAction):
            new_actions = list(
                build_field_action_chain(action_cls, left_schema, right_schema,
                                         document_types))
        elif issubclass(action_cls, BaseIndexAction):
            new_actions = list(
                build_index_action_chain(action_cls, left_schema, right_schema,
                                         document_types))
        else:
            continue

        for action in new_actions:
            log.debug('> %s', action)
            try:
                left_schema = patch(action.to_schema_patch(left_schema),
                                    left_schema)
            except (TypeError, ValueError, KeyError) as e:
                raise ActionError(
                    f"Unable to apply schema patch of {action!r}. More likely that the "
                    f"schema is corrupted. You can use schema repair tools to fix this issue"
                ) from e
        action_chain.extend(new_actions)
        document_types = get_all_document_types(left_schema, right_schema)

    if right_schema != left_schema:
        log.error(
            'Schema is still not reached the target state after applying all actions. '
            'Changes left to make (diff): %s',
            list(diff(left_schema, right_schema)))
        raise ActionError(
            'Could not reach target schema state after applying whole Action chain. '
            'This could be a problem in some Action which does not process schema '
            'properly or produces wrong schema diff. This is a programming error'
        )

    return action_chain
Example #2
0
def build_document_action_chain(action_cls: Type[BaseDocumentAction],
                                left_schema: Schema,
                                right_schema: Schema,
                                document_types: Iterable[str]) -> Iterable[BaseAction]:
    """
    Walk through schema changes, and produce chain of Action objects
    of given type which could handle schema changes from left to right
    :param action_cls: Action type to consider
    :param left_schema:
    :param right_schema:
    :param document_types: list of document types to inspect
    :return: iterable of suitable Action objects
    """
    for document_type in document_types:
        action_obj = action_cls.build_object(document_type, left_schema, right_schema)
        if action_obj is not None:
            try:
                left_schema = patch(action_obj.to_schema_patch(left_schema), left_schema)
            except (TypeError, ValueError, KeyError) as e:
                raise ActionError(
                    f"Unable to apply schema patch of {action_obj!r}. More likely that the "
                    f"schema is corrupted. You can use schema repair tools to fix this issue"
                ) from e

            yield action_obj
Example #3
0
    def makemigrations(self):
        """
        Compare current mongoengine documents state and the last db
        state and make a migration file if needed
        """
        log.debug('Loading migration files...')
        graph = self.build_graph()
        log.debug('Loading schema from database...')
        db_schema = self.load_db_schema()

        # Obtain schema changes which migrations would make (including
        #  unapplied ones)
        # If mongoengine models schema was changed regarding db schema
        #  then try to guess which actions would reflect such changes
        for migration in graph.walk_down(graph.initial, unapplied_only=False):
            for action_object in migration.get_actions():
                try:
                    db_schema = patch(action_object.to_schema_patch(db_schema),
                                      db_schema)
                except (TypeError, ValueError, KeyError) as e:
                    raise ActionError(
                        f"Unable to apply schema patch of {action_object!r}. More likely that the "
                        f"schema is corrupted. You can use schema repair tools to fix this issue"
                    ) from e

        log.debug('Collecting schema from mongoengine documents...')
        models_schema = collect_models_schema()
        if db_schema == models_schema:
            log.info('No changes detected')
            return

        log.debug('Building actions chain...')
        actions_chain = build_actions_chain(db_schema, models_schema)

        import_expressions = {'from mongoengine_migrate.actions import *'}
        for action in actions_chain:
            # If `regex` is set in action, then we probably need 're'
            if isinstance(action.parameters.get('regex'), re.Pattern):
                import_expressions.add('import re')
                break

        log.debug('Writing migrations file...')
        env = Environment()
        env.filters['symbol_wrap'] = symbol_wrap
        tpl_ctx = {
            'graph': graph,
            'actions_chain': actions_chain,
            'policy_enum': MigrationPolicy,
            'import_expressions': import_expressions
        }
        tpl_path = Path(__file__).parent / 'migration_template.tpl'
        tpl = env.from_string(tpl_path.read_text())
        migration_source = tpl.render(tpl_ctx)

        seq_number = str(len(graph.migrations)).zfill(4)
        name = f'{seq_number}_auto_{datetime.now().strftime("%Y%m%d_%H%M")}.py'
        migration_file = Path(self.migration_dir) / name
        migration_file.write_text(migration_source)

        log.info('Migration file "%s" was created', migration_file)
Example #4
0
    def __init__(self, document_type, *, forward_func=None, backward_func=None, **kwargs):
        super().__init__(document_type, **kwargs)

        if forward_func is None and backward_func is None:
            raise ActionError("forward_func and backward_func are not set")

        self.forward_func = forward_func
        self.backward_func = backward_func
Example #5
0
    def __init__(self, document_type: str, field_name: str, **kwargs):
        """
        :param document_type: collection name to be affected
        :param field_name: changing mongoengine document field name
        """
        super().__init__(document_type, **kwargs)
        self.field_name = field_name

        db_field = kwargs.get('db_field')
        if db_field and '.' in db_field:
            raise ActionError(f"db_field must not contain dots "
                              f"{self.document_type}.{self.field_name}")
Example #6
0
    def upgrade(self,
                migration_name: str,
                graph: Optional[MigrationsGraph] = None):
        """
        Upgrade db to the given migration
        :param migration_name: target migration name
        :param graph: Optional. Migrations graph. If omitted, then it
         will be loaded
        :return:
        """
        if graph is None:
            log.debug('Loading migration files...')
            graph = self.build_graph()
        log.debug('Loading schema from database...')
        left_schema = self.load_db_schema()

        if migration_name not in graph.migrations:
            raise MigrationGraphError(f'Migration {migration_name} not found')

        db = self.db
        for migration in graph.walk_down(graph.initial, unapplied_only=True):
            log.info('Upgrading %s...', migration.name)
            for idx, action_object in enumerate(migration.get_actions(),
                                                start=1):
                log.debug('> [%d] %s', idx, str(action_object))
                if not action_object.dummy_action and not runtime_flags.schema_only:
                    action_object.prepare(db, left_schema, migration.policy)
                    action_object.run_forward()
                    action_object.cleanup()

                try:
                    left_schema = patch(
                        action_object.to_schema_patch(left_schema),
                        left_schema)
                except (TypeError, ValueError, KeyError) as e:
                    raise ActionError(
                        f"Unable to apply schema patch of {action_object!r}. More likely that the "
                        f"schema is corrupted. You can use schema repair tools to fix this issue"
                    ) from e

            graph.migrations[migration.name].applied = True

            if not runtime_flags.dry_run:
                log.debug('Writing db schema and migrations graph...')
                self.write_db_schema(left_schema)
                self.write_db_migrations_graph(graph)

            if migration.name == migration_name:
                break  # We've reached the target migration

        self._verify_schema(left_schema)
Example #7
0
    def build_schema(cls, field_obj: mongoengine.fields.BaseField) -> dict:
        """
        Return schema of a given mongoengine field object

        'type_key' schema item will get filled with a mongoengine field
        class name or one of its parents which have its own type key
        in registry
        :param field_obj: mongoengine field object
        :return: field schema dict
        """
        schema_skel = cls.schema_skel()
        schema = {
            f: getattr(field_obj, f, val)
            for f, val in schema_skel.items()
        }

        if 'default' in schema:
            schema['default'] = cls._normalize_default(schema['default'])

        if 'choices' in schema:
            schema['choices'] = cls._normalize_choices(schema['choices'])

        field_class = field_obj.__class__
        if field_class.__name__ in type_key_registry:
            schema['type_key'] = field_class.__name__
        else:
            registry_field_cls = get_closest_parent(
                field_class, (x.field_cls for x in type_key_registry.values()))
            if registry_field_cls is None:
                raise ActionError(
                    f'Could not find {field_class!r} or one of its base classes '
                    f'in type_key registry')

            schema['type_key'] = registry_field_cls.__name__

        return schema
Example #8
0
def collect_models_schema() -> Schema:
    """
    Transform all available mongoengine document objects to db schema
    :return:
    """
    schema = Schema()
    collections: Dict[str,
                      set] = {}  # {collection_name: set(top_level_documents)}

    # Retrieve models from mongoengine global document registry
    for model_cls in _document_registry.values():
        log.debug('> Reading document %s', repr(model_cls))
        # NOTE: EmbeddedDocuments are not append 'abstract' in meta if
        # `meta` is defined
        if model_cls._meta.get('abstract'):
            log.debug('> Skip %s since it is an abstract document')
            continue

        document_type = get_document_type(model_cls)
        if document_type is None:
            raise ActionError(f'Could not get document type for {model_cls!r}')

        if document_type in schema:
            raise ActionError(
                f'Models with the same document types {document_type!r} found')

        schema[document_type] = Schema.Document()
        if not document_type.startswith(
                runtime_flags.EMBEDDED_DOCUMENT_NAME_PREFIX):
            col = schema[document_type].parameters[
                'collection'] = model_cls._get_collection_name()

            # Determine if unrelated documents have the same collection
            # E.g. DropDocument could drop all of these documents
            top_lvl_doc = document_type.split(
                runtime_flags.DOCUMENT_NAME_SEPARATOR)[0]
            collections.setdefault(col, set())
            if collections[col] and top_lvl_doc not in collections[col]:
                collections[col].add(top_lvl_doc)
                log.warning(
                    f'These mongoengine documents use the same collection '
                    f'which can be a cause of data corruption: {collections[col]}'
                )
            else:
                collections[col].add(top_lvl_doc)

        if model_cls._meta.get('allow_inheritance'):
            schema[document_type].parameters['inherit'] = True

        if model_cls._dynamic:
            schema[document_type].parameters['dynamic'] = True

        # {field_cls: TypeKeyRegistryItem}
        field_mapping_registry = {
            x.field_cls: x
            for x in type_key_registry.values()
        }

        # Collect schema for every field
        for field_name, field_obj in model_cls._fields.items():
            # Exclude '_id' special MongoDB field since it is immutable
            # and should not be a part of migrations
            if field_obj.db_field == '_id':
                continue

            field_cls = field_obj.__class__

            if field_cls in field_mapping_registry:
                registry_field_cls = field_cls
            else:
                registry_field_cls = get_closest_parent(
                    field_cls, field_mapping_registry.keys())

            if registry_field_cls is None:
                raise ActionError(
                    f'Could not find {field_cls!r} or one of its base classes '
                    f'in type_key registry')

            handler_cls = field_mapping_registry[
                registry_field_cls].field_handler_cls
            schema[document_type][field_name] = handler_cls.build_schema(
                field_obj)
            # TODO: validate default against all field restrictions such as min_length, regex, etc.

        log.debug("> Schema '%s' => %s", document_type,
                  str(schema[document_type]))

    return schema
Example #9
0
    def downgrade(self,
                  migration_name: str,
                  graph: Optional[MigrationsGraph] = None):
        """
        Downgrade db to the given migration
        :param migration_name: target migration name
        :param graph: Optional. Migrations graph. If omitted, then it
         will be loaded
        :return:
        """
        if graph is None:
            log.debug('Loading migration files...')
            graph = self.build_graph()
        log.debug('Loading schema from database...')
        left_schema = self.load_db_schema()

        if migration_name not in graph.migrations:
            raise MigrationGraphError(f'Migration {migration_name} not found')

        log.debug('Precalculating schema diffs...')
        # Collect schema diffs across all migrations
        migration_diffs = {}  # {migration_name: [action1_diff, ...]}
        temp_left_schema = Schema()
        for migration in graph.walk_down(graph.initial, unapplied_only=False):
            migration_diffs[migration.name] = []
            for action in migration.get_actions():
                forward_patch = action.to_schema_patch(temp_left_schema)
                migration_diffs[migration.name].append(forward_patch)

                try:
                    temp_left_schema = patch(forward_patch, temp_left_schema)
                except (TypeError, ValueError, KeyError) as e:
                    raise ActionError(
                        f"Unable to apply schema patch of {action!r}. More likely that the "
                        f"schema is corrupted. You can use schema repair tools to fix this issue"
                    ) from e

        db = self.db
        for migration in graph.walk_up(graph.last, applied_only=True):
            if migration.name == migration_name:
                break  # We've reached the target migration

            log.info('Downgrading %s...', migration.name)

            action_diffs = zip(migration.get_actions(),
                               migration_diffs[migration.name],
                               range(1,
                                     len(migration.get_actions()) + 1))
            for action_object, action_diff, idx in reversed(
                    list(action_diffs)):
                log.debug('> [%d] %s', idx, str(action_object))

                try:
                    left_schema = patch(list(swap(action_diff)), left_schema)
                except (TypeError, ValueError, KeyError) as e:
                    raise ActionError(
                        f"Unable to apply schema patch of {action_object!r}. More likely that the "
                        f"schema is corrupted. You can use schema repair tools to fix this issue"
                    ) from e

                if not action_object.dummy_action and not runtime_flags.schema_only:
                    action_object.prepare(db, left_schema, migration.policy)
                    action_object.run_backward()
                    action_object.cleanup()

            graph.migrations[migration.name].applied = False

            if not runtime_flags.dry_run:
                log.debug('Writing db schema and migrations graph...')
                self.write_db_schema(left_schema)
                self.write_db_migrations_graph(graph)

        self._verify_schema(left_schema)