Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

model faker library #261

Merged
merged 24 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/file-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ This is inperformant! We have the size field.
Instead of iterating through the objects, we just sum up the sizes in db per table via the sum operator


TODO: Example
```python
{!> ../docs_src/fields/files/file_with_size.py !}
```



### Metadata
Expand Down
6 changes: 5 additions & 1 deletion docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ hide:

# Release Notes

## 0.24.3
## 0.25.0

### Added

- Add `testing.factory.ModelFactory`.
- ManyToManyField `create_through_model` method allows now the keyword only argument `replace_related_field`.
- `add_to_registry` and models have now an additional keyword-only argument `on_conflict` for controlling what happens when a same named model already exists.
For models this can be passed : `class Foo(edgy.Model, on_conflict="keep"): ...`.
Expand All @@ -27,6 +28,7 @@ hide:
- Through models use now `no_copy` when autogenerated. This way they don't land in copied registries but are autogenerated again.
- Instead of silent replacing models with the same `__name__` now an error is raised.
- `skip_registry` has now also an allowed literal value: `"allow_search"`. It enables the search of the registry but doesn't register the model.
- Move `testclient` to `testing` but keep a forward reference.

### Fixed

Expand All @@ -37,6 +39,8 @@ hide:
- ManyToMany and ForeignKey fields didn't worked when referencing tenant models.
- ManyToMany fields didn't worked when specified on tenant models.
- Fix transaction method to work on instance and class.
- Fix missing file conversion in File. Move from ContentFile.
- Fix mypy crashing after the cache was build (cause ChoiceField annotation).

### BREAKING

Expand Down
6 changes: 6 additions & 0 deletions docs/testing/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Testing

Edgy provides two testing facilities:

- A [Testclient](test-client.md), which has some testing-only methods and parameters.
- A faker based ModelFactory [ModelFactory](./model-factory.md)
184 changes: 184 additions & 0 deletions docs/testing/model-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# ModelFactory

A ModelFactory is a faker based model stub generator.

In the first step, building the factory class, you can define via `FactoryField`s customizations of the parameters passed
for the fakers for the model.

The second step, is making a factory instance. Here can values be passed which should be used for the model. They are baked in
the factory instance. But you are able to overwrite them in the last step or to exclude them.

The last step, is building a stub model via `build`. This is an **instance-only** method not like the other build method other model definitions.

In short the sequence is:

Factory definition -> Factory instance -> Factory build method -> stubbed Model instance to play with.

You can reuse the factory instance to produce a lot of models.

Example:

```python
{!> ../docs_src/testing/factory/factory_basic.py !}
```

Now we have a basic working model. Now let's get more complicated. Let's remove the implicit id field via factory fields

devkral marked this conversation as resolved.
Show resolved Hide resolved
```python
{!> ../docs_src/testing/factory/factory_fields_exclude.py !}
```

devkral marked this conversation as resolved.
Show resolved Hide resolved
!!! Note
Every Factory class has an own internal faker instance. If you require a separate faker you have to provide it in the build method
as `faker` keyword parameter.

## Parametrize

For customization you have two options: provide parameters to the corresponding faker method or to provide an own callable which can also receive parameters.
When no callback is provided the mappings are used which use the field type name of the corresponding edgy field.

E.g. CharFields use the "CharField" mapping.

```python
{!> ../docs_src/testing/factory/factory_parametrize.py !}
```

You can also overwrite the field_type on FactoryField base. This can be used to parametrize
fields differently. E.g. ImageFields like a FileField or a CharField like PasswordField.

```python
{!> ../docs_src/testing/factory/factory_field_overwrite.py !}
```

In case you want to overwrite a mapping completely for all subclasses you can use the Meta `mappings` attribute.

```python
{!> ../docs_src/testing/factory/factory_mapping.py !}
```

Setting a mapping to `None` will disable a stubbing by default.
You will need to re-enable via setting the mapping in a subclass to a mapping function.

```python
{!> ../docs_src/testing/factory/factory_mapping2.py !}
```

!!! Tip
You can name a FactoryField differently and provide the name parameter explicitly. This way it is possible to workaround occluded fields.

### Setting database and schema

By default the database and schema of the model used is unchanged. You can however provide an other database or schema than the default by defining
them as class or instance variables (not by keyword arguments) on a Factory.
The syntax is the same as the one used for database models, you define them on the main model. You can also overwrite them one-time in the build method.

- `__using_schema__` (str or None)
- `database` (Database or None)

!!! Note
There is a subtle difference between database models and ModelFactories concerning `__using_schema__`.
When `None` in `ModelFactory` the default of the model is used while in database models None selects the main schema.

devkral marked this conversation as resolved.
Show resolved Hide resolved

### Parametrizing relation fields

Relation fields are fields like ForeignKey ManyToMany, OneToOne and RelatedField.

To parametrize relation fields there are two variants:

1. Pass `build()` parameters as field parameters. For 1-n relations there are two extra parameters min=0, max=100, which allow to specify how many
instances are generated.
2. Transform a ModelFactory to a FactoryField.

The first way cannot be used with RelatedFields, which are automatically excluded.
You can however pass values to them via the second way.

To transform a ModelFactory there are two helper classmethods:

1. `to_factory_field`
2. `to_list_factory_field(min=0, max=100)`


### Special parameters

There are two special parameters which are always available for all fields:

- randomly_unset
- randomly_nullify

The first randomly excludes a field value. The second randomly sets a value to None.
You can either pass True for a equal distribution or a number from 0-100 to bias it.

devkral marked this conversation as resolved.
Show resolved Hide resolved
### Excluding a field

To exclude a field there are three ways

- Provide a field with `exclude=True`. It should be defined under the name of the value.
- Add the field name to the exclude parameter of build.
- Raise `edgy.testing.exceptions.ExcludeValue` in a callback.

Let's revisit one of the first examples. Here the id field is excluded by a different named FactoryField.

```python
devkral marked this conversation as resolved.
Show resolved Hide resolved
{!> ../docs_src/testing/factory/factory_fields_exclude.py !}
```

Note: However that the FactoryField can only be overwritten by its provided name or in case it is unset its implicit name.
When multiple fields have the same name, the last found in the same class is overwritting the other.

Otherwise the mro order is used.

Here an example using both other ways:

```python
{!> ../docs_src/testing/factory/factory_exclude.py !}
```

## Build

The central method for factories is `build()`. It generates the model instance.
It has also some keyword parameters for post-customization. They are also available for default relationship fields
or for wrapping factory fields via the `to_factory_field` or `to_list_factory_field` classmethods.

The parameters are:

- **faker** (not available for factories for relationship fields. Here is the provided faker or faker of the parent model used). Provide a custom Faker instance.
This can be useful when the seed is modified.
- **parameters** ({fieldname: {parametername: parametervalue} | FactoryCallback}): Provide per field name either a callback which returns the value or parameters.
- **overwrites** ({fieldname: value}): Provide the value directly. Skip any evaluation
- **exclude** (e.g. {"id"}): Exclude the values from stubbing. Useful for removing the autogenerated id.
- **database** (Database | None | False): Use a different database. When None pick the one of the ModelFactory if available, then fallback to the model.
When `False`, just use the one of the model.
- **schema** (str | None | False): Use a different schema. When `None` pick the one of the ModelFactory if available, then fallback to the model.
When `False`, just use the one of the model.

devkral marked this conversation as resolved.
Show resolved Hide resolved
```python
{!> ../docs_src/testing/factory/factory_build.py !}
```

### Saving

Saving isn't done in build(). It must be done after seperately.
When requiring saving you should exclude autoincrement fields otherwise strange collissions can happen.

```python
{!> ../docs_src/testing/factory/factory_save.py !}
```

## Model Validation

By default a validation is executed if the model can ever succeed in generation. If not an error
is printed but the model still builds.
If you dislike this behaviour, you can disable the implicit model validation via:

```python
class UserFactory(ModelFactory, model_validation="none"):
...
```

You have following options:

- `none`: No implicit validation.
- `warn`: Warn for unsound factory/model definitions which produce other errors than pydantic validation errors. Default.
- `error`: Same as warn but reraise the exception instead of a warning.
- `pedantic`: Raise even for pydantic validation errors.
2 changes: 1 addition & 1 deletion docs/test-client.md → docs/testing/test-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Pretty cool, right?
Nothing like an example to see it in action.

```python title="tests.py" hl_lines="14"
{!> ../docs_src/testclient/tests.py !}
{!> ../docs_src/testing/testclient/tests.py !}
```

#### What is happening
Expand Down
23 changes: 23 additions & 0 deletions docs_src/fields/files/file_with_size.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import os
from typing import Any
from sqlalchemy import func

import edgy
from edgy.exceptions import FileOperationError

models = edgy.Registry(database=...)


class Document(edgy.StrictModel):
file: edgy.files.FieldFile = edgy.fields.FileField(with_size=True)

class Meta:
registry = models


async def main():
document = await Document.query.create(file=b"abc")
document2 = await Document.query.create(file=b"aabc")
document3 = await Document.query.create(file=b"aabcc")

sum_of_size = await Document.database.fetch_val(func.sum(Document.columns.file_size))
29 changes: 29 additions & 0 deletions docs_src/testing/factory/factory_basic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import enum


import edgy
from edgy.testing.factory import ModelFactory, FactoryField

models = edgy.Registry(database=...)


class User(edgy.Model):
name: str = edgy.CharField(max_length=100, null=True)
language: str = edgy.CharField(max_length=200, null=True)

class Meta:
registry = models


class UserFactory(ModelFactory):
class Meta:
model = User

language = FactoryField(callback="language_code")


user_factory = UserFactory(language="eng")

user_model_instance = user_factory.build()
# provide the name edgy
user_model_instance_with_name_edgy = user_factory.build(overwrites={"name": "edgy"})
51 changes: 51 additions & 0 deletions docs_src/testing/factory/factory_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import enum


import edgy
from edgy.testing.client import DatabaseTestClient
from edgy.testing.factory import ModelFactory, FactoryField

test_database1 = DatabaseTestClient(...)
test_database2 = DatabaseTestClient(...)
models = edgy.Registry(database=...)


class User(edgy.Model):
name: str = edgy.CharField(max_length=100, null=True)
language: str = edgy.CharField(max_length=200, null=True)
password = edgy.fields.PasswordField(max_length=100)

class Meta:
registry = models


class UserFactory(ModelFactory):
class Meta:
model = User

language = FactoryField(callback="language_code")
database = test_database1
__using_schema__ = "test_schema1"


user_factory = UserFactory(language="eng")

user_model_instance = user_factory.build()


# customize later
user_model_instance_with_name_edgy = user_factory.build(
overwrites={"name": "edgy"},
parameters={"password": {"special_chars": False}},
exclude={"language"},
)


# customize later, with different database and schema
user_model_instance_with_name_edgy = user_factory.build(
overwrites={"name": "edgy"},
parameters={"password": {"special_chars": False}},
exclude={"language"},
database=test_database2,
schema="test_schema2",
)
33 changes: 33 additions & 0 deletions docs_src/testing/factory/factory_exclude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import enum


import edgy
from edgy.testing.exceptions import ExcludeValue
from edgy.testing.factory import ModelFactory, FactoryField

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


def callback(field_instance, faker, parameters):
raise ExcludeValue


class User(edgy.Model):
name: str = edgy.CharField(max_length=100, null=True)
language: str = edgy.CharField(max_length=200, null=True)

class Meta:
registry = models


class UserFactory(ModelFactory):
class Meta:
model = User

language = FactoryField(callback=callback)


user_factory = UserFactory()

user_model_instance = user_factory.build(exclude={"name"})
Loading
Loading