使用Django 1.7+和数据迁移加载初始数据

105

我最近从Django 1.6升级到1.7,并开始使用迁移(我以前从未使用过South)。

在1.7之前,我通常使用 fixture/initial_data.json 文件来加载初始数据,在创建数据库时使用python manage.py syncdb命令进行加载。

现在,我开始使用迁移,这种行为已被弃用:

如果应用程序使用迁移,则不会自动加载夹具。由于Django 2.0中将需要应用程序使用迁移,因此认为该行为已被弃用。如果您想为应用程序加载初始数据,请考虑在数据迁移中执行。 (https://docs.djangoproject.com/en/1.7/howto/initial-data/#automatically-loading-initial-data-fixtures)

官方文档没有提供明确的示例说明如何实现此操作,因此我的问题是:

使用数据迁移导入此类初始数据的最佳方法是什么:

  1. 编写Python代码并多次调用mymodel.create(...)
  2. 使用或编写Django函数(像调用loaddata一样)从JSON夹具文件中加载数据。

我更喜欢第二个选项。

我不想使用South,因为Django现在似乎能够本地完成它。


3
另外,我想在OP的原始问题中添加另一个问题:对于不属于我们应用程序的数据,我们应该如何进行数据迁移。例如,如果有人正在使用站点框架,则需要具有包含站点数据的fixture。由于站点框架与我们的应用程序无关,那么我们应该将该数据迁移放在哪里?谢谢! - Serafeim
这里还没有被任何人提及的一个重要问题是,当您需要将数据迁移中定义的数据添加到已经伪造迁移的数据库中时会发生什么。由于迁移是伪造的,您的数据迁移将不会运行,您必须手动完成它。此时,您可以考虑直接在fixture文件上调用loaddata。 - hekevintran
另一个有趣的情况是,如果你需要进行数据迁移以创建例如auth.Group实例,而后你又想要创建新的群组作为种子数据,那么你就需要创建一个新的数据迁移。这会很麻烦,因为你的群组种子数据将分布在多个文件中。此外,在你想要重置迁移的情况下,你还需要查找设置种子数据的数据迁移并将它们一起传输。 - hekevintran
1
@Serafeim 问题“在哪里放置第三方应用程序的初始数据”不会因为使用数据迁移而改变,因为你只是改变了数据加载的方式。我通常使用一个小型自定义应用程序来处理这类事情。如果第三方应用程序叫做“foo”,那么我会将包含数据迁移/fixture 的简单应用程序命名为“foo_integration”。 - guettli
@guettli 是的,可能使用额外的应用程序是最好的方法! - Serafeim
8个回答

90
更新:请查看@GwynBleidD在下面发表的评论,了解此解决方案可能引起的问题,并查看@Rockallite在下面发表的答案,了解更加耐用于未来模型更改的方法。

假设你有一个固定文件在<yourapp>/fixtures/initial_data.json

  1. 创建空迁移:

    在Django 1.7中:

    python manage.py makemigrations --empty <yourapp>
    

    在Django 1.8及以上版本中,您可以提供一个名称:

    python manage.py makemigrations --empty <yourapp> --name load_intial_data
    
  2. 编辑您的迁移文件<yourapp>/migrations/0002_auto_xxx.py

    2.1. 受Django' loaddata启发的自定义实现(初始答案):

  3. import os
    from sys import path
    from django.core import serializers
    
    fixture_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../fixtures'))
    fixture_filename = 'initial_data.json'
    
    def load_fixture(apps, schema_editor):
        fixture_file = os.path.join(fixture_dir, fixture_filename)
    
        fixture = open(fixture_file, 'rb')
        objects = serializers.deserialize('json', fixture, ignorenonexistent=True)
        for obj in objects:
            obj.save()
        fixture.close()
    
    def unload_fixture(apps, schema_editor):
        "Brutally deleting all entries for this model..."
    
        MyModel = apps.get_model("yourapp", "ModelName")
        MyModel.objects.all().delete()
    
    class Migration(migrations.Migration):  
    
        dependencies = [
            ('yourapp', '0001_initial'),
        ]
    
        operations = [
            migrations.RunPython(load_fixture, reverse_code=unload_fixture),
        ]
    

    2.2. 根据 @juliocesar 的建议,load_fixture 的一个更简单的解决方案:

    from django.core.management import call_command
    
    fixture_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../fixtures'))
    fixture_filename = 'initial_data.json'
    
    def load_fixture(apps, schema_editor):
        fixture_file = os.path.join(fixture_dir, fixture_filename)
        call_command('loaddata', fixture_file) 
    

    如果您想使用自定义目录,则非常有用。

    2.3. 最简单的方法: 使用app_label调用loaddata将自动从<yourapp>fixtures目录中加载数据:

    from django.core.management import call_command
    
    fixture = 'initial_data'
    
    def load_fixture(apps, schema_editor):
        call_command('loaddata', fixture, app_label='yourapp') 
    
    如果您不指定app_label,loaddata将尝试从所有应用程序fixtures目录中加载fixture文件名(这可能不是您想要的)。运行它。
    python manage.py migrate <yourapp>
    

1
好的,你是对的... 同时调用loaddata('loaddata',fixture_filename,app_label =' <yourapp> ')也将直接进入应用程序fixture目录(因此无需构建fixture的完整路径) - n__o
16
通过这种方法,序列化器将处理当前models.py文件中的模型状态,该文件可能具有一些额外字段或其他更改。如果在创建迁移后进行了某些更改,它将失败(因此我们甚至不能在该迁移之后创建架构迁移)。为了解决这个问题,我们可以暂时更改应用程序注册表,使其适用于迁移函数提供的注册表,即第一个参数。路径到注册表位于django.core.serializers.python.apps - GwynBleidD
3
为什么要这样做?为什么Django越来越难以运行和维护?我不想经历这一切,我希望有一个简单的命令行界面可以解决这个问题,就像使用fixtures时一样简单。 Django应该使这些事情变得更容易,而不是更难 :( - CpILL
1
@GwynBleidD,你提出的观点非常重要,我认为它应该出现在这个被接受的答案中。这是与文档中数据迁移代码示例的评论相同的评论。你知道另一种使用序列化程序和提供的“app registry”的方法吗?并且不需要更改全局变量(这可能会在假设未来的并行数据库迁移中导致问题)。 - Ad N
3
这个回答被投票并被接受,这正是我建议大家不要使用stackoverflow的原因。即使现在有评论和轶事,我仍然有人在#django中提到这个问题。 - shangxiao
显示剩余8条评论

62

简短版

在数据迁移中,不应该直接使用loaddata管理命令。

# Bad example for a data migration
from django.db import migrations
from django.core.management import call_command


def load_fixture(apps, schema_editor):
    # No, it's wrong. DON'T DO THIS!
    call_command('loaddata', 'your_data.json', app_label='yourapp')


class Migration(migrations.Migration):
    dependencies = [
        # Dependencies to other migrations
    ]

    operations = [
        migrations.RunPython(load_fixture),
    ]

长版

loaddata使用django.core.serializers.python.Deserializer,该工具在迁移中使用最新的模型来反序列化历史数据。这是不正确的行为。

例如,假设有一个数据迁移,它利用loaddata管理命令从fixture加载数据,并且已经应用于您的开发环境。

后来,您决定向对应的模型添加一个新的必填字段,因此您对更新的模型进行了修改,并创建了一次新的迁移(当./manage.py makemigrations提示您时,可能会提供新字段的一次性值)。

您运行下一个迁移,一切都很顺利。

最后,您完成了Django应用程序的开发,并将其部署在生产服务器上。现在是在生产环境中从头开始运行所有迁移的时候了。

然而,数据迁移失败了。这是因为loaddata命令中反序列化的模型代表当前代码,无法使用新添加的必填字段的空数据保存。原始的fixture缺少必要的数据!

但是,即使您使用所需数据更新fixture,数据迁移仍然失败。当数据迁移正在运行时,下一个迁移将添加相应的列到数据库中,但它尚未应用。您无法将数据保存到不存在的列中!

结论:在数据迁移中,loaddata命令会在模型和数据库之间引入潜在的不一致性。您绝对不应该直接在数据迁移中使用它。

解决方案

loaddata命令依赖于django.core.serializers.python._get_model函数,该函数从fixture获取相应的模型,它将返回最新版本的模型。我们需要对其进行monkey-patch,以便它获取历史模型。

(以下代码适用于Django 1.8.x)

# Good example for a data migration
from django.db import migrations
from django.core.serializers import base, python
from django.core.management import call_command


def load_fixture(apps, schema_editor):
    # Save the old _get_model() function
    old_get_model = python._get_model

    # Define new _get_model() function here, which utilizes the apps argument to
    # get the historical version of a model. This piece of code is directly stolen
    # from django.core.serializers.python._get_model, unchanged. However, here it
    # has a different context, specifically, the apps variable.
    def _get_model(model_identifier):
        try:
            return apps.get_model(model_identifier)
        except (LookupError, TypeError):
            raise base.DeserializationError("Invalid model identifier: '%s'" % model_identifier)

    # Replace the _get_model() function on the module, so loaddata can utilize it.
    python._get_model = _get_model

    try:
        # Call loaddata command
        call_command('loaddata', 'your_data.json', app_label='yourapp')
    finally:
        # Restore old _get_model() function
        python._get_model = old_get_model


class Migration(migrations.Migration):
    dependencies = [
        # Dependencies to other migrations
    ]

    operations = [
        migrations.RunPython(load_fixture),
    ]

2
Rockallite,你提出了一个非常有力的观点。不过你的回答让我想知道,@n__o/@mlissner的答案中的解决方案2.1是否会遇到与loaddata相同的问题,因为它依赖于objects = serializers.deserialize('json', fixture, ignorenonexistent=True)?或者ignorenonexistent=True可以涵盖所有可能的问题? - Dário
7
如果你查看源代码,你会发现ignorenonexistent=True参数有两个作用:1)它会忽略夹具中不在最新模型定义中的模型,2)它会忽略夹具模型中不在最新相应模型定义中的字段。但是,这两种情况都无法处理“模型中的新必需字段”的情况。所以,是的,我认为它和普通的loaddata一样存在问题。 - Rockallite
1
一旦我发现我的旧JSON使用natural_key()引用其他模型,而这种方法似乎不支持该功能后,这个方法的效果非常好 - 我只需将natural_key值替换为所引用模型的实际ID。 - dsummersl
1
可能将此答案作为被接受的答案会更有帮助,因为在运行测试用例时会创建一个新的数据库,并从头应用所有迁移。这个解决方案可以解决在数据迁移中未替换 _get_model 时单元测试项目所面临的问题。谢谢。 - Mohammad ali baghershemirani
感谢更新和解释,@Rockallite。我的最初答案是在 Django 1.7 引入迁移几周后发布的,当时如何进行操作的文档不清楚(我上次检查时仍然如此)。希望 Django 有一天会更新他们的 loaddata / 迁移机制以考虑模型历史记录。 - n__o
显示剩余6条评论

7

受到一些评论(特别是n__o的)的启发,以及我在多个应用程序中分散存在许多initial_data.*文件的事实,我决定创建一个Django应用程序来促进这些数据迁移的创建。

使用django-migration-fixture,您只需运行以下管理命令,它将搜索所有INSTALLED_APPS中的initial_data.*文件,并将它们转换为数据迁移。

./manage.py create_initial_data_fixtures
Migrations for 'eggs':
  0002_auto_20150107_0817.py:
Migrations for 'sausage':
  Ignoring 'initial_data.yaml' - migration already exists.
Migrations for 'foo':
  Ignoring 'initial_data.yaml' - not migrated.

安装和使用说明请参见django-migration-fixture


2
为了给您的数据库添加一些初始数据,请编写一个数据迁移。 在数据迁移中,使用RunPython函数来加载您的数据。
不要编写任何loaddata命令,因为这种方式已经过时了。
您的数据迁移将只运行一次。迁移是有序的迁移序列。当运行003_xxxx.py迁移时,Django迁移会在数据库中写入此应用程序已迁移至此(003),并且仅运行以下迁移。

那么你鼓励我在RunPython函数中重复调用myModel.create(...)(或使用循环)? - Mickaël
基本上没错。事务性数据库可以完美处理它 :) - FlogFR

2
很遗憾,上述解决方案对我没有用。我发现每次更改我的模型时,都必须更新我的固定装置。理想情况下,我应该编写数据迁移来修改创建的数据和装载的数据。

为了实现这一点,我编写了一个快速函数,它将查找当前应用程序的fixtures目录并加载一个fixture。将此函数放入迁移中,使其与迁移中的字段匹配。


谢谢!我写了一个与Python 3兼容的版本(并通过了我们严格的Pylint测试)。您可以将其用作工厂,使用RunPython(load_fixture('badger','stoat'))。https://gist.github.com/danni/1b2a0078e998ac080111 - Danielle Madeley

2
在Django 2.1中,我想要加载一些模型(例如国家名称)的初始数据。但我希望这在执行初始迁移后自动发生。因此,我认为在每个需要加载初始数据的应用程序内部创建一个名为sql/的文件夹将会很好。然后,在该sql/文件夹中,我将有包含所需DML的.sql文件,以将初始数据加载到相应的模型中,例如:
INSERT INTO appName_modelName(fieldName)
VALUES
    ("country 1"),
    ("country 2"),
    ("country 3"),
    ("country 4");

更具体地说,一个包含sql/文件夹的应用程序将如下所示: enter image description here 我发现有些情况下需要按特定顺序执行sql脚本。因此,我决定像上面图片中那样在文件名前使用连续数字作为前缀。
然后,我需要一种方法来自动加载任何应用程序文件夹中可用的SQLs,只需运行python manage.py migrate即可。
因此,我创建了另一个名为initial_data_migrations的应用程序,并将其添加到settings.py文件中的INSTALLED_APPS列表中。然后,我在其中创建了一个migrations文件夹,并添加了一个名为run_sql_scripts.py的文件(实际上是自定义迁移)。如下图所示: enter image description here 我创建了run_sql_scripts.py,以便它负责运行每个应用程序中所有可用的sql脚本。当有人运行python manage.py migrate时,就会启动这个脚本。此自定义迁移还将所涉及的应用程序添加为依赖项,因此它仅在必需的应用程序执行其0001_initial.py迁移后尝试运行sql语句(我们不希望尝试针对不存在的表运行SQL语句)。
以下是该脚本的源代码:
import os
import itertools

from django.db import migrations
from YourDjangoProjectName.settings import BASE_DIR, INSTALLED_APPS

SQL_FOLDER = "/sql/"

APP_SQL_FOLDERS = [
    (os.path.join(BASE_DIR, app + SQL_FOLDER), app) for app in INSTALLED_APPS
    if os.path.isdir(os.path.join(BASE_DIR, app + SQL_FOLDER))
]

SQL_FILES = [
    sorted([path + file for file in os.listdir(path) if file.lower().endswith('.sql')])
    for path, app in APP_SQL_FOLDERS
]


def load_file(path):
    with open(path, 'r') as f:
        return f.read()


class Migration(migrations.Migration):

    dependencies = [
        (app, '__first__') for path, app in APP_SQL_FOLDERS
    ]

    operations = [
        migrations.RunSQL(load_file(f)) for f in list(itertools.chain.from_iterable(SQL_FILES))
    ]

我希望这篇文章能对你有所帮助,它对我来说非常有效!如果您有任何问题,请告诉我。

注意:这可能不是最佳解决方案,因为我刚开始使用Django,但我仍然想与大家分享这个"如何",因为在谷歌上找不到太多关于此的信息。


也许我错了...但是如果你修改了一个.sql文件,甚至添加了一个新的.sql文件,python manage.py migrate load_initial_data 将不会检测到任何更改。因此,这对于真正静态的初始数据非常有用,不允许进行任何更改。虽然如此,它仍然比被接受的答案要好。 - glezo

1
在我看来,夹具有点糟糕。如果您的数据库经常更改,保持它们的最新状态很快就会变成一场噩梦。实际上,不仅仅是我的意见,在书籍“Django两球冰淇淋”中解释得更好。
相反,我会编写一个Python文件来提供初始设置。如果您需要更多内容,我建议您查看Factory boy
如果您需要迁移一些数据,您应该使用data migrations
还有"Burn Your Fixtures, Use Model Factories"关于使用固定装置的内容。

1
我同意你的观点“如果经常更改,难以维护”,但这里的装置只旨在在安装项目时提供初始(和最少)数据... - Mickaël
1
这是一次性数据加载,如果在迁移的上下文中完成,则有意义。因为如果它在迁移中,就不应该对json数据进行更改。任何需要更改数据的模式更改都应通过另一个迁移来处理(此时数据库中可能还有其他需要修改的数据)。 - mtnpaul

0

尽管@rockallite的答案很好,但它没有解释如何处理依赖于自然键而不是整数pk值的固定装置。

简化版本

首先,请注意@rockallite的解决方案可以通过使用unittest.mock.patch作为上下文管理器并补丁apps而简化,而不是补丁_get_model

...
from unittest.mock import patch
...

def load_fixture(apps, schema_editor):
    with patch('django.core.serializers.python.apps', apps):
        call_command('loaddata', 'your_data.json', ...)

...

只要你的固定装置不依赖于自然键,这个方法就很有效。

如果它们依赖于自然键,你可能会看到一个DeserializationError: ... value must be an integer...

自然键的问题

在幕后, loaddata使用django.core.serializers.deserialize()来加载你的固定装置对象。

基于自然键的固定装置的反序列化依赖于两个因素

  1. 模型的默认管理器上存在get_by_natural_key()方法
  2. 模型本身上存在natural_key()方法

get_by_natural_key()方法对于反序列化程序来说是必要的,以便知道如何解释自然键,而不是整数pk值。

这两种方法都是必要的,以便反序列化程序可以通过自然键从数据库中获取现有对象,正如此处所解释的那样。

然而,在您的迁移中可用的apps注册表使用历史模型,这些模型无法访问自定义管理器或自定义方法,例如natural_key()

可能的解决方案:步骤1

我们自定义的模型管理器缺少get_by_natural_key()方法的问题相对容易解决: 只需在自定义管理器上设置use_in_migrations=True如文档所述

这样可以确保您的历史模型在迁移期间可以访问当前的get_by_natural_key(),并且夹具加载现在应该成功了。

但是,您的历史模型仍然没有natural_key()方法。因此,即使它们已经存在于数据库中,您的夹具也将被视为新对象。 如果数据迁移再次应用,这可能会导致各种错误,例如:

  • 唯一约束冲突(如果您的模型具有唯一约束)
  • 重复的夹具对象(如果您的模型没有唯一约束)
  • “获取多个对象”错误(由于先前创建的重复夹具对象)

因此,在反序列化期间,您仍然错过了一种类似于get_or_create的行为。

要体验这个过程,只需按照上述描述在测试环境中应用数据迁移,然后回滚相同的数据迁移(不删除数据),然后重新应用数据迁移。

可能的解决方案:第二步

模型本身缺少natural_key()方法的问题有点难以解决。 一种解决方案是将当前模型的natural_key()方法分配给历史模型,例如:

...
from unittest.mock import patch

from django.apps import apps as current_apps
from django.core.management import call_command
...


def load_fixture(apps, schema_editor):
    def _get_model_patch(app_label):
        """ add natural_key method from current model to historical model """
        historical_model = apps.get_model(app_label=app_label)
        current_model = current_apps.get_model(app_label=app_label)
        historical_model.natural_key = current_model.natural_key
        return historical_model

    with patch('django.core.serializers.python._get_model', _get_model_patch):
        call_command('loaddata', 'your_data.json', ...)

...

注:

  • 为了清晰起见,我在示例中省略了错误处理和属性检查等内容。您应该在必要时实现这些内容。
  • 此解决方案使用当前模型的natural_key方法,在某些情况下仍可能导致问题,但对于Django的模型管理器use_in_migrations选项也是如此。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接