Skip to content
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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,45 @@ data.json
}
```

## Editor validation (JSON Schema)

A JSON Schema for seed files ships with the package and lives in the repo at
[`src/sqlalchemyseed/res/schema.json`](src/sqlalchemyseed/res/schema.json). Point
your editor at it to get autocomplete and inline validation as you write fixtures.

In the URLs below, replace `v2.4.0` with the version of sqlalchemyseed you have
installed, so the editor validates against the same rules as your runtime.

For YAML files, add a modeline as the first line:

```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/jedymatt/sqlalchemyseed/v2.4.0/src/sqlalchemyseed/res/schema.json
- model: models.Person
data:
name: John March
age: 23
```

For JSON files (which can't carry a modeline), associate the schema by glob in
your editor settings, e.g. VS Code `.vscode/settings.json`:

```json
{
"yaml.schemas": {
"https://raw.githubusercontent.com/jedymatt/sqlalchemyseed/v2.4.0/src/sqlalchemyseed/res/schema.json": "seeds/**/*.yaml"
},
"json.schemas": [
{
"fileMatch": ["seeds/**/*.json"],
"url": "https://raw.githubusercontent.com/jedymatt/sqlalchemyseed/v2.4.0/src/sqlalchemyseed/res/schema.json"
}
]
}
```

The schema covers the full format including the `!` relationship prefix; the
`filter` key it allows is only honored by `HybridSeeder`.

## Command-line usage

Seed a database directly from data files without writing Python:
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,8 @@ version = { attr = "sqlalchemyseed.__version__" }
[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
sqlalchemyseed = ["res/*.json"]

[tool.uv]
default-groups = ["dev"]
10 changes: 6 additions & 4 deletions src/sqlalchemyseed/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ class MissingKeyError(Exception):
"""Raised when a required key is missing"""


class MaxLengthExceededError(Exception):
"""Raised when maximum length of data exceeded"""


class InvalidTypeError(Exception):
"""Raised when a type of data is not accepted"""

Expand All @@ -22,6 +18,12 @@ class InvalidKeyError(Exception):
"""Raised when an invalid key is invoked"""


# Deprecated alias: sqlalchemyseed<=2.4.0 raised MaxLengthExceededError for
# entities with too many keys; those cases now raise InvalidKeyError. Kept so
# existing imports and except clauses keep working.
MaxLengthExceededError = InvalidKeyError


class ParseError(Exception):
"""Raised when parsing string fails"""

Expand Down
116 changes: 60 additions & 56 deletions src/sqlalchemyseed/res/schema.json
Original file line number Diff line number Diff line change
@@ -1,93 +1,97 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"description": "sqlalchemyseed json schema",
"title": "sqlalchemyseed seed data",
"description": "Schema for sqlalchemyseed JSON/YAML seed files. Mirrors sqlalchemyseed.validator. Note: the 'filter' key is only honored by HybridSeeder; the basic Seeder accepts 'data' only.",
"definitions": {
"field": {
"type": "object",
"additionalProperties": {
"allOf": [
{
"if": {
"type": "object"
},
"then": {
"$ref": "#/definitions/entity"
}
},
{
"if": {
"type": "array"
},
"then": {
"items": {
"$ref": "#/definitions/entity"
}
}
"child_ref": {
"$comment": "Value of a '!'-prefixed relationship attribute: one child entity, or a list of them (empty list = no related rows).",
"anyOf": [
{
"$ref": "#/definitions/entity"
},
{
"type": "array",
"items": {
"$ref": "#/definitions/entity"
}
]
}
}
]
},
"fields": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/field"
"record": {
"$comment": "A single row's attributes. Keys starting with '!' are relationships (nested entities); all other keys are plain column values.",
"type": "object",
"patternProperties": {
"^!": {
"$ref": "#/definitions/child_ref"
}
}
},
"args": {
"$comment": "Value of a 'data'/'filter' key: one record, or a non-empty list of records.",
"anyOf": [
{
"$comment": "Object data type goes here",
"$ref": "#/definitions/field"
"$ref": "#/definitions/record"
},
{
"$comment": "Array data type goes here",
"$ref": "#/definitions/fields"
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/record"
}
}
]
},
"entity": {
"$comment": "A child entity: 'model' is optional (inferred from the parent relationship). Closed key set; exactly one of 'data'/'filter'.",
"type": "object",
"anyOf": [
"additionalProperties": false,
"properties": {
"model": {
"type": "string"
},
"data": {
"$ref": "#/definitions/args"
},
"filter": {
"$ref": "#/definitions/args"
}
},
"oneOf": [
{
"required": [
"model",
"data"
]
},
{
"required": [
"model",
"filter"
]
}
],
"properties": {
"model": {
"type": "string"
},
"data": {
"$ref": "#/definitions/args"
]
},
"parent_entity": {
"$comment": "A top-level entity: same as a child entity but 'model' is required (there is no parent relationship to infer it from).",
"allOf": [
{
"$ref": "#/definitions/entity"
},
"filter": {
"$ref": "#/definitions/args"
{
"required": [
"model"
]
}
}
},
"entities": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/entity"
}
]
}
},
"anyOf": [
{
"$ref": "#/definitions/entity"
"$ref": "#/definitions/parent_entity"
},
{
"$ref": "#/definitions/entities"
"type": "array",
"items": {
"$ref": "#/definitions/parent_entity"
}
}
]
}
}
40 changes: 27 additions & 13 deletions src/sqlalchemyseed/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,28 @@ def check_model_key(entity: dict, entity_is_parent: bool):
raise errors.InvalidTypeError("'model' data should be 'string'.")


def check_max_length(entity: dict):
if len(entity) > 2:
raise errors.MaxLengthExceededError("Length should not exceed by 2.")
def check_keys(entity: dict, source_keys: list):
allowed = {Key.model().name, *(key.name for key in source_keys)}
unknown = [key for key in entity if key not in allowed]
if unknown:
raise errors.InvalidKeyError(
f"Unexpected key(s): {', '.join(map(str, unknown))}. "
f"Allowed keys: {', '.join(sorted(allowed))}.")


def check_source_key(entity: dict, source_keys: list) -> Key:
source_key: Key = next(
(sk for sk in source_keys if sk in entity),
None
)
present = [key for key in source_keys if key in entity]

# check if current keys has at least, data or filter key
if source_key is None:
if len(present) == 0:
raise errors.MissingKeyError(
f"Missing {', '.join(map(str, source_keys))} key(s).")

return source_key
if len(present) > 1:
raise errors.InvalidKeyError(
f"Expected exactly one of {', '.join(map(str, source_keys))}, "
f"but found: {', '.join(map(str, present))}.")

return present[0]


def check_source_data(source_data, source_key: Key):
Expand Down Expand Up @@ -98,16 +103,20 @@ def _pre_validate(self, entities: dict, entity_is_parent=True):
if not isinstance(entities, dict) and not isinstance(entities, list):
raise errors.InvalidTypeError(
"Invalid type, should be list or dict")
if len(entities) == 0:
return
# An empty parent dict (or list) means "seed nothing" and stays valid
# for backward compatibility with placeholder seed files. An empty
# child dict is a malformed reference (missing 'model'/source key), so
# it falls through to _validate.
if isinstance(entities, dict):
if len(entities) == 0 and entity_is_parent:
return
return self._validate(entities, entity_is_parent)
# iterate list
for entity in entities:
self._pre_validate(entity, entity_is_parent)

def _validate(self, entity: dict, entity_is_parent=True):
check_max_length(entity)
check_keys(entity, self._source_keys)
check_model_key(entity, entity_is_parent)

# get source key, either data or filter key
Expand All @@ -127,6 +136,11 @@ def _validate(self, entity: dict, entity_is_parent=True):
self.check_attributes(source_data)

def check_attributes(self, source_data: dict):
for attr_name in source_data:
if not isinstance(attr_name, str):
raise errors.InvalidTypeError(
f"Invalid attribute name {attr_name!r}, "
"attribute names should be 'string'.")
for _, value in util.iter_ref_kwargs(source_data, self._ref_prefix):
self._pre_validate(value, entity_is_parent=False)

Expand Down
51 changes: 47 additions & 4 deletions tests/instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,60 @@
'model': 9_999
}

PARENT_WITH_EXTRA_LENGTH_INVALID = {
PARENT_WITH_EMPTY_DATA = {
'model': 'tests.models.Company',
'data': {}
}

PARENT_EMPTY_DICT = {}

PARENT_WITH_UNKNOWN_KEY_INVALID = {
'model': 'tests.models.Company',
'data': {
'name': 'My Company'
},
'extra': 'extra value'
'flter': {} # typo of 'filter' — an unknown key
}

PARENT_WITH_EMPTY_DATA = {
PARENT_WITH_NON_STRING_ATTRIBUTE_INVALID = {
'model': 'tests.models.Company',
'data': {}
'data': {
1: 'My Company' # e.g. an unquoted numeric key in YAML
}
}

PARENT_WITH_DATA_AND_FILTER_INVALID = {
'model': 'tests.models.Company',
'data': {},
'filter': {}
}

PARENT_TO_CHILD_EMPTY_INVALID = {
'model': 'tests.models.Employee',
'data': {
'name': 'Juan Dela Cruz',
'!company': {}
}
}

PARENT_TO_CHILD_UNKNOWN_KEY_INVALID = {
'model': 'tests.models.Employee',
'data': {
'name': 'Juan Dela Cruz',
'!company': {
'data': {
'name': 'Juan Company'
},
'flter': {} # unknown key on a child, within the 2-key budget
}
}
}

BASIC_PARENT_WITH_FILTER_INVALID = {
'model': 'tests.models.Company',
'filter': {
'name': 'My Company'
}
}

PARENT_WITHOUT_DATA_INVALID = {
Expand Down
Loading
Loading