diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..d808b3a
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,11 @@
+[run]
+branch = True
+source = src/
+
+[report]
+exclude_lines:
+ @(abc\.)?abstractmethod
+ pragma: no cover
+ def __repr__
+ if __name__ == .__main__.:
+skip_empty = True
diff --git a/.editorconfig b/.editorconfig
index 9e63122..3de3253 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,7 +5,7 @@ charset = utf-8
end_of_line = crlf
indent_size = 4
indent_style = space
-insert_final_newline = false
+insert_final_newline = true
max_line_length = 120
tab_width = 4
ij_continuation_indent_size = 8
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..314766e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+* text=auto eol=lf
+*.{cmd,[cC][mM][dD]} text eol=crlf
+*.{bat,[bB][aA][tT]} text eol=crlf
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..ba1c6b8
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "pip" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "daily"
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 5e9b9a3..68fb1c3 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -5,30 +5,33 @@ name: Python package
on:
push:
- branches: [main, v0.4.x]
+ branches: [main]
pull_request:
- branches: [main, v0.4.x]
+ branches: [main]
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- python-version: [3.6, 3.7, 3.8, 3.9]
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v2
+ uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
- if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+ pip install -r requirements.txt
+ - name: Install the sqlalchemyseed
+ run: pip install -e .
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 9554faa..3bfabfc 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -22,7 +22,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v2
with:
- python-version: '3.6'
+ python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
diff --git a/.gitignore b/.gitignore
index a5be596..e63fa36 100644
--- a/.gitignore
+++ b/.gitignore
@@ -140,4 +140,4 @@ cython_debug/
# IDE Generated directories
.idea/
-.vscode/
\ No newline at end of file
+.vscode/
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..6b74ca3
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,571 @@
+[MASTER]
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
+# for backward compatibility.)
+extension-pkg-whitelist=
+
+# Return non-zero exit code if any of these messages/categories are detected,
+# even if score is above --fail-under value. Syntax same as enable. Messages
+# specified are enabled, while categories only check already-enabled messages.
+fail-on=
+
+# Specify a score threshold to be exceeded before program exits with error.
+fail-under=10.0
+
+# Files or directories to be skipped. They should be base names, not paths.
+ignore=CVS
+
+# Add files or directories matching the regex patterns to the ignore-list. The
+# regex matches against paths and can be in Posix or Windows format.
+ignore-paths=
+
+# Files or directories matching the regex patterns are skipped. The regex
+# matches against base names, not paths.
+ignore-patterns=
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use.
+jobs=1
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Minimum Python version to use for version dependent checks. Will default to
+# the version used to run pylint.
+py-version=3.10
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
+confidence=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then reenable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=raw-checker-failed,
+ bad-inline-option,
+ locally-disabled,
+ file-ignored,
+ suppressed-message,
+ useless-suppression,
+ deprecated-pragma,
+ use-symbolic-message-instead,
+ # custom disable rule
+ too-few-public-methods,
+ missing-module-docstring,
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'error', 'warning', 'refactor', and 'convention'
+# which contain the number of messages in each category, as well as 'statement'
+# which is the total number of statements analyzed. This score is used by the
+# global evaluation report (RP0004).
+evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+#msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit,argparse.parse_error
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+ bar,
+ baz,
+ toto,
+ tutu,
+ tata
+
+# Bad variable names regexes, separated by a comma. If names match any regex,
+# they will always be refused
+bad-names-rgxs=
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style.
+#class-attribute-rgx=
+
+# Naming style matching correct class constant names.
+class-const-naming-style=UPPER_CASE
+
+# Regular expression matching correct class constant names. Overrides class-
+# const-naming-style.
+#class-const-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=snake_case
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+ j,
+ k,
+ ex,
+ Run,
+ _
+
+# Good variable names regexes, separated by a comma. If names match any regex,
+# they will always be accepted
+good-names-rgxs=
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=snake_case
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=snake_case
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style.
+#variable-rgx=
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )??$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
+# tab).
+indent-string=' '
+
+# Maximum number of characters on a single line.
+max-line-length=100
+
+# Maximum number of lines in a module.
+max-module-lines=1000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
+
+
+[LOGGING]
+
+# The type of string formatting that logging methods do. `old` means using %
+# formatting, `new` is for `{}` formatting.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+ XXX,
+ TODO
+
+# Regular expression of note tags to take in consideration.
+#notes-rgx=
+
+
+[SIMILARITIES]
+
+# Comments are removed from the similarity computation
+ignore-comments=yes
+
+# Docstrings are removed from the similarity computation
+ignore-docstrings=yes
+
+# Imports are removed from the similarity computation
+ignore-imports=no
+
+# Signatures are removed from the similarity computation
+ignore-signatures=no
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. Available dictionaries: none. To make it work,
+# install the 'python-enchant' package.
+spelling-dict=
+
+# List of comma separated words that should be considered directives if they
+# appear and the beginning of a comment and should not be checked.
+spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=no
+
+# This flag controls whether the implicit-str-concat should generate a warning
+# on implicit string concatenation in sequences defined over several lines.
+check-str-concat-over-line-jumps=no
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# class is considered mixin if its name matches the mixin-class-rgx option.
+ignore-mixin-members=yes
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# Regex pattern to define which classes are considered mixins ignore-mixin-
+# members is set to 'yes'
+mixin-class-rgx=.*[Mm]ixin
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of names allowed to shadow builtins
+allowed-redefined-builtins=
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+ _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored. Default to name
+# with leading underscore.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+
+
+[CLASSES]
+
+# Warn about protected attribute access inside special methods
+check-protected-access-in-special-methods=no
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+ __new__,
+ setUp,
+ __post_init__
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+ _fields,
+ _replace,
+ _source,
+ _make
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=cls
+
+
+[DESIGN]
+
+# List of regular expressions of class ancestor names to ignore when counting
+# public methods (see R0903)
+exclude-too-few-public-methods=
+
+# List of qualified class names to ignore when counting class parents (see
+# R0901)
+ignored-parents=
+
+# Maximum number of arguments for function / method.
+max-args=5
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=12
+
+# Maximum number of locals for function / method body.
+max-locals=15
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=6
+
+# Maximum number of statements in function / method body.
+max-statements=50
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=
+
+# Output a graph (.gv or any supported image format) of external dependencies
+# to the given file (report RP0402 must not be disabled).
+ext-import-graph=
+
+# Output a graph (.gv or any supported image format) of all (i.e. internal and
+# external) dependencies to the given file (report RP0402 must not be
+# disabled).
+import-graph=
+
+# Output a graph (.gv or any supported image format) of internal dependencies
+# to the given file (report RP0402 must not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "BaseException, Exception".
+overgeneral-exceptions=BaseException,
+ Exception
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..22cf63d
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,21 @@
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+ configuration: docs/conf.py
+ builder: html
+ # fail_on_warning: true
+
+# Optionally build your docs in additional formats such as PDF
+formats: all
+
+# Optionally set the version of Python and requirements required to build your docs
+python:
+ version: "3.8"
+ install:
+ - requirements: docs/requirements.txt
diff --git a/.travis.yml b/.travis.yml
index 0f0298e..111ef4d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,16 +4,21 @@ python:
- "3.7"
- "3.8"
- "3.9"
-# os:
-# - linux
-# - osx
-# - windows
+os:
+ - linux
before_install:
- pip3 install --upgrade pip
-
install:
- pip install -r requirements.txt
- - pip install .
- - pip install pytest
+# don't use the line below because codecov generates a false 'miss'
+# - pip install . --use-feature=in-tree-build
+ - pip install -e .
script:
- - pytest
+ # - pytest tests
+ - coverage run -m pytest tests
+ - coverage xml
+after_success:
+# - bash <(curl -s https://codecov.io/bash)
+ - bash <(curl -Os https://uploader.codecov.io/latest/linux/codecov)
+ - bash <(chmod +x codecov)
+ - ./codecov
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c6b9e95
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1 @@
+# CONTRIBUTING
diff --git a/README.md b/README.md
index 091cfad..a41cc2d 100644
--- a/README.md
+++ b/README.md
@@ -4,261 +4,111 @@
[](https://pypi.org/project/sqlalchemyseed)
[](https://github.com/jedymatt/sqlalchemyseed/blob/main/LICENSE)
[](https://github.com/jedymatt/sqlalchemyseed/actions/workflows/python-package.yml)
-[](https://app.travis-ci.com/jedymatt/sqlalchemyseed)
+[](https://codeclimate.com/github/jedymatt/sqlalchemyseed/maintainability)
+[](https://codecov.io/gh/jedymatt/sqlalchemyseed)
+[](https://sqlalchemyseed.readthedocs.io/en/latest/?badge=latest)
Sqlalchemy seeder that supports nested relationships.
+Supported file types
+
+- json
+- yaml
+- csv
+
## Installation
Default installation
-```commandline
+```shell
pip install sqlalchemyseed
```
-When using yaml to loading entities from yaml files.
-Execute this command to install necessary dependencies
-
-```commandline
-pip install sqlalchemyseed[yaml]
-```
-
-## Dependencies
-
-Required
-
-- SQAlchemy>=1.4.0
-
-Optional
+## Quickstart
-- PyYAML>=5.4.0
-
-## Getting Started
+main.py
```python
-# main.py
-from sqlalchemyseed import load_entities_from_json, Seeder
+from sqlalchemyseed import load_entities_from_json
+from sqlalchemyseed import Seeder
from db import session
# load entities
-entities = load_entities_from_json('tests/test_data.json')
+entities = load_entities_from_json('data.json')
# Initializing Seeder
-seeder = Seeder() # or Seeder(session)
+seeder = Seeder(session)
# Seeding
-seeder.session = session # assign session if no session assigned before seeding
seeder.seed(entities)
# Committing
session.commit() # or seeder.session.commit()
```
-## Seeder vs. HybridSeeder
+data.json
-| Features & Options | Seeder | HybridSeeder |
-| :--------------------------------------------------------------------- | :----------------- | :----------------- |
-| Support `model` and `data` keys | :heavy_check_mark: | :heavy_check_mark: |
-| Support `model` and `filter` keys | :x: | :heavy_check_mark: |
-| Optional argument `add_to_session=False` in the `seed` method | :heavy_check_mark: | :x: |
-| Assign existing objects from session or db to a relationship attribute | :x: | :heavy_check_mark: |
+```json
+{
+ "model": "models.Person",
+ "data": [
+ {
+ "name": "John March",
+ "age": 23
+ },
+ {
+ "name": "Juan Dela Cruz",
+ "age": 21
+ }
+ ]
+}
+```
-## When to use HybridSeeder and 'filter' key field?
+## Documentation
-Assuming that `Child(age=5)` exists in the database or session,
-then we should use *filter* instead of *data*,
-the values of *filter* will query from the database or session,
-and assign it to the `Parent.child`
+
-```python
-from sqlalchemyseed import HybridSeeder
-from db import session
+## Found Bug?
-data = {
- "model": "models.Parent",
- "data": {
- "!child": {
- "model": "models.Child",
- "filter": {
- "age": 5
- }
- }
- }
-}
+Report here in this link:
+
+## Want to contribute?
-# When seeding instances that has 'filter' key, then use HybridSeeder, otherwise use Seeder.
-seeder = HybridSeeder(session)
-seeder.seed(data)
+First, Clone this [repository](https://github.com/jedymatt/sqlalchemyseed).
-session.commit() # or seeder.sesssion.commit()
-```
+### Install dev dependencies
-## Relationships
+Inside the folder, paste this in the terminal to install necessary dependencies:
-In adding a relationship attribute, add prefix **!** to the key in order to identify it.
+```shell
+pip install -r requirements.txt -r docs/requirements.txt
+```
-### Referencing relationship object or a foreign key
+Note: make sure you have the virtual environment and enabled, or if you are using vs code and docker then you can simply re-open this as container.
-If your class don't have a relationship attribute but instead a foreign key attribute you can use it the same as how you did it on a relationship attribute
+### Run tests
-```python
-from sqlalchemyseed import HybridSeeder
-from db import session
+Before running tests, make sure that the package is installed as editable:
-instance = [
- {
- 'model': 'tests.models.Company',
- 'data': {'name': 'MyCompany'}
- },
- {
- 'model': 'tests.models.Employee',
- 'data':[
- {
- 'name': 'John Smith',
- # foreign key attribute
- '!company_id': {
- 'model': 'tests.models.Company',
- 'filter': {
- 'name': 'MyCompany'
- }
- }
- },
- {
- 'name': 'Juan Dela Cruz',
- # relationship attribute
- '!company': {
- 'model': 'tests.models.Company',
- 'filter': {
- 'name': 'MyCompany'
- }
- }
- ]
- }
-]
-
-seeder = HybridSeeder(session)
-seeder.seed(instance)
+```shell
+python setup.py develop --user
```
-### No Relationship
+Then run the test:
-```json5
-// test_data.json
-[
- {
- "model": "models.Person",
- "data": {
- "name": "You",
- "age": 18
- }
- },
- // when you have two or more objects of the same model, you can
- {
- "model": "models.Person",
- "data": [
- {
- "name": "You",
- "age": 18
- },
- {
- "name": "Still You But Older",
- "age": 40
- }
- ]
- }
-]
+```shell
+pytest tests
```
-### One to One
-
-```json5
-// test_data.json
-[
- {
- "model": "models.Person",
- "data": {
- "name": "John",
- "age": 18,
- // creates a job object
- "!job": {
- "model": "models.Job",
- "data": {
- "job_name": "Programmer",
- }
- }
- }
- },
- // or this, if you want to add relationship that exists
- // in your database use 'filter' instead of 'obj'
- {
- "model": "models.Person",
- "data": {
- "name": "Jeniffer",
- "age": 18,
- "!job": {
- "model": "models.Job",
- "filter": {
- "job_name": "Programmer",
- }
- }
- }
- }
-]
-```
+Run test with coverage
-### One to Many
-
-```json5
-//test_data.json
-[
- {
- "model": "models.Person",
- "data": {
- "name": "John",
- "age": 18,
- "!items": [
- {
- "model": "models.Item",
- "data": {
- "name": "Pencil"
- }
- },
- {
- "model": "models.Item",
- "data": {
- "name": "Eraser"
- }
- }
- ]
- }
- }
-]
+```shell
+coverage run -m pytest
```
-### Example of Nested Relationships
+Autobuild documentation
-```json
-{
- "model": "models.Parent",
- "data": {
- "name": "John Smith",
- "!children": [
- {
- "model": "models.Child",
- "data": {
- "name": "Mark Smith",
- "!children": [
- {
- "model": "models.GrandChild",
- "data": {
- "name": "Alice Smith"
- }
- }
- ]
- }
- }
- ]
- }
-}
+```shell
+sphinx-autobuild docs docs/_build/html
```
diff --git a/TODO.md b/TODO.md
index 07d7950..17fcc9b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,7 +1,19 @@
# TODO
-- [x] HybridSeeder filter from foreign key id
-- [x] HybridSeeder
-- [x] Seeder
-- [x] Validator
-- [x] Added prefix '!' to relationship attributes
+## v1.x
+
+- [x] Add example of input in csv file in README.md
+- [x] Support load entities from csv
+- [x] Customize prefix in seeder (default=`!`)
+- [x] Customize prefix in validator (default=`!`)
+- [x] relationship entity no longer required `model` key since the program will search it for you, but can also be
+ overridden by providing a model data instead as it saves performance time
+- [x] Add test case for overriding default reference prefix
+- [ ] Update README description
+- [x] Add docstrings
+- [ ] Refactor test instances and test cases
+- [ ] add PersistentSeeder refer to [this image](persistent-seeder.png)
+
+## Tentative Plans
+
+- Support load entities from excel
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d4bb2cb
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..8800c26
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,67 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+import sys
+sys.path.insert(0, os.path.abspath('.'))
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'sqlalchemyseed'
+copyright = '2022, jedymatt'
+author = 'jedymatt'
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+ 'sphinx.ext.autodoc',
+ 'sphinx_copybutton',
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+# html_theme = 'alabaster'
+html_theme = 'furo'
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# autoapi.extension configuration
+extensions.append('autoapi.extension')
+
+autoapi_type = 'python'
+autoapi_dirs = ['../src']
+autoapi_options = ['members', 'undoc-members', 'show-inheritance']
+autoapi_add_toctree_entry = True
+autoapi_generate_api_docs = True
+autoapi_root = 'api'
+autoapi_file_patterns = ['*.py']
+autoapi_member_order = 'groupwise'
diff --git a/docs/examples.rst b/docs/examples.rst
new file mode 100644
index 0000000..c5280cb
--- /dev/null
+++ b/docs/examples.rst
@@ -0,0 +1,177 @@
+Examples
+========
+
+json
+----
+
+.. code-block:: json
+
+ {
+ "model": "models.Person",
+ "data": [
+ {
+ "name": "John March",
+ "age": 23
+ },
+ {
+ "name": "Juan Dela Cruz",
+ "age": 21
+ }
+ ]
+ }
+
+yaml
+----
+
+.. code-block:: yaml
+
+ model: models.Person
+ data:
+ - name: John March
+ age: 23
+ - name: Juan Dela Cruz
+ age: 21
+
+csv
+---
+
+In line one, name and age,
+are attributes of a model that will be specified when loading the file.
+
+.. code-block:: none
+
+ name, age
+ John March, 23
+ Juan Dela Cruz, 21
+
+To load a csv file
+
+.. code-block:: python
+
+ # second argument, model, accepts class
+ load_entities_from_csv("people.csv", models.Person)
+ # or string
+ load_entities_from_csv("people.csv", "models.Person")
+
+.. note::
+ csv does not support referencing relationships.
+
+
+No Relationship
+---------------
+
+.. code-block:: json
+
+ [
+ {
+ "model": "models.Person",
+ "data": {
+ "name": "You",
+ "age": 18
+ }
+ },
+ {
+ "model": "models.Person",
+ "data": [
+ {
+ "name": "You",
+ "age": 18
+ },
+ {
+ "name": "Still You But Older",
+ "age": 40
+ }
+ ]
+ }
+ ]
+
+
+One to One Relationship
+-----------------------
+
+.. code-block:: json
+
+ [
+ {
+ "model": "models.Person",
+ "data": {
+ "name": "John",
+ "age": 18,
+ "!job": {
+ "model": "models.Job",
+ "data": {
+ "job_name": "Programmer",
+ }
+ }
+ }
+ },
+ {
+ "model": "models.Person",
+ "data": {
+ "name": "Jeniffer",
+ "age": 18,
+ "!job": {
+ "model": "models.Job",
+ "filter": {
+ "job_name": "Programmer",
+ }
+ }
+ }
+ }
+ ]
+
+One to Many Relationship
+------------------------
+
+.. code-block:: json
+
+ [
+ {
+ "model": "models.Person",
+ "data": {
+ "name": "John",
+ "age": 18,
+ "!items": [
+ {
+ "model": "models.Item",
+ "data": {
+ "name": "Pencil"
+ }
+ },
+ {
+ "model": "models.Item",
+ "data": {
+ "name": "Eraser"
+ }
+ }
+ ]
+ }
+ }
+ ]
+
+Nested Relationships
+
+.. code-block:: json
+
+ {
+ "model": "models.Parent",
+ "data": {
+ "name": "John Smith",
+ "!children": [
+ {
+ "model": "models.Child",
+ "data": {
+ "name": "Mark Smith",
+ "!children": [
+ {
+ "model": "models.GrandChild",
+ "data": {
+ "name": "Alice Smith"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..0a8bc73
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,30 @@
+.. sqlalchemyseed documentation master file, created by
+ sphinx-quickstart on Fri May 27 23:04:46 2022.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to sqlalchemyseed's documentation!
+==========================================
+
+SQLAlchemy seeder that supports nested relationships with an easy to read text files.
+
+Project Links: `Github`_ | `PyPI`_
+
+.. _Github: https://github.com/jedymatt/sqlalchemyseed
+.. _PyPI: https://pypi.org/project/sqlalchemyseed
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+ intro
+ seeding
+ relationships
+ examples
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/intro.rst b/docs/intro.rst
new file mode 100644
index 0000000..866c489
--- /dev/null
+++ b/docs/intro.rst
@@ -0,0 +1,79 @@
+Introduction
+============
+
+`sqlalchemyseed`_ is a SQLAlchemy seeder that supports nested relationships
+with an easy to read text files.
+
+Supported file types :
+
+- json
+- yaml
+- csv
+
+.. _sqlalchemyseed: https://github.com/jedymatt/sqlalchemyseed
+
+Installation
+------------
+
+Default installation
+
+.. code-block:: shell
+
+ pip install sqlalchemyseed
+
+When using yaml to load entities from yaml files,
+execute this command to install necessary dependencies
+
+.. code-block:: shell
+
+ pip install sqlalchemyseed[yaml]
+
+Dependencies
+------------
+
+- Required dependencies:
+ - SQAlchemy>=1.4.0
+- Optional dependencies:
+ - yaml: PyYAML>=5.4.0
+
+Quickstart
+----------
+
+Here's a simple snippet to get started from ``main.py`` file.
+
+.. code-block:: python
+
+ from sqlalchemyseed import load_entities_from_json
+ from sqlalchemyseed import Seeder
+ from db import session
+
+ # load entities
+ entities = load_entities_from_json('data.json')
+
+ # Initializing Seeder
+ seeder = Seeder(session)
+
+ # Seeding
+ seeder.seed(entities)
+
+ # Committing
+ session.commit() # or seeder.session.commit()
+
+
+And the ``data.json`` file.
+
+.. code-block:: json
+
+ {
+ "model": "models.Person",
+ "data": [
+ {
+ "name": "John March",
+ "age": 23
+ },
+ {
+ "name": "Juan Dela Cruz",
+ "age": 21
+ }
+ ]
+ }
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..954237b
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/relationships.rst b/docs/relationships.rst
new file mode 100644
index 0000000..a3bee57
--- /dev/null
+++ b/docs/relationships.rst
@@ -0,0 +1,104 @@
+Referencing Relationships
+==========================
+
+To add reference attribute,
+add prefix to the attribute to differentiate reference attribute from normal ones.
+
+.. code-block:: json
+
+ {
+ "model": "models.Employee",
+ "data": {
+ "name": "John Smith",
+ "!company": {
+ "model": "models.Company",
+ "data": {
+ "name": "MyCompany"
+ }
+ }
+ }
+ }
+
+Base on the example above, **name** is a normal attribute and **!company** is a reference attribute
+which translates to ``Employee.name`` and ``Employee.company``, respectively.
+
+.. note::
+ The default reference prefix is ``!`` and can be customized.
+
+Customizing reference prefix
+----------------------------
+
+If you want ``@`` as prefix,
+you can just specify it to what seeder you use by
+assigning value of ``Seeder.ref_prefix`` or ``HybridSeeeder.ref_prefix``.
+Default value is ``!``
+
+.. code-block:: python
+
+ seeder = Seeder(session, ref_prefix='@')
+ # or
+ seeder = Seeder(session)
+ seeder.ref_prefix = '@'
+
+
+Types of reference attributes
+-----------------------------
+
+Reference attribute types:
+
+- foreign key attribute
+- relationship attribute
+
+You can reference a foreign key and relationship attribute in the same way.
+For example:
+
+.. code-block:: python
+
+ from sqlalchemyseed import HybridSeeder
+ from db import session
+
+ instance = {
+ 'model': 'tests.models.Employee',
+ 'data': [
+ {
+ 'name': 'John Smith',
+ '!company_id': { # this is the foreign key attribute
+ 'model': 'tests.models.Company',
+ 'filter': {
+ 'name': 'MyCompany'
+ }
+ }
+ },
+ {
+ 'name': 'Juan Dela Cruz',
+ '!company': { # this is the relationship attribute
+ 'model': 'tests.models.Company',
+ 'filter': {
+ 'name': 'MyCompany'
+ }
+ }
+ ]
+ }
+
+ seeder = HybridSeeder(session)
+ seeder.seed(instance)
+ seeder.session.commit()
+
+.. note::
+ ``model`` can be removed if the attribute is a reference attribute like this:
+
+ .. code-block:: json
+
+ {
+ "model": "models.Employee",
+ "data": {
+ "name": "Juan Dela Cruz",
+ "!company": {
+ "data": {
+ "name": "Juan's Company"
+ }
+ }
+ }
+ }
+
+ Notice above that ``model`` is removed in ``!company``.
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..8c69612
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,11 @@
+sphinx>=4.3
+
+# Themes
+furo
+
+# Extentions
+sphinx-copybutton
+sphinx-autoapi
+
+# Others
+sphinx-autobuild
diff --git a/docs/seeding.rst b/docs/seeding.rst
new file mode 100644
index 0000000..04a8d82
--- /dev/null
+++ b/docs/seeding.rst
@@ -0,0 +1,62 @@
+Seeding
+=======
+
+Seeder vs. HybridSeeder
+-----------------------
+
+.. list-table::
+ :widths: auto
+ :header-rows: 1
+
+ * - Features & Options
+ - Seeder
+ - HybridSeeder
+
+ * - Support ``model`` and ``data`` keys
+ - ✔️
+ - ✔️
+
+ * - Support ``model`` and ``filter`` keys
+ - ❌
+ - ✔️
+
+ * - Optional argument ``add_to_session=False`` in the ``seed`` method
+ - ✔️
+ - ❌
+
+
+When to use HybridSeeder and 'filter' key field?
+------------------------------------------------
+
+Assuming that ``Child(age=5)`` exists in the database or session,
+then we should use ``filter`` instead of ``data`` key.
+
+The values from ``filter`` will query from the database or session,
+and get the result then assign it to the ``Parent.child``
+
+.. code-block:: python
+
+ from sqlalchemyseed import HybridSeeder
+ from db import session
+
+ data = {
+ "model": "models.Parent",
+ "data": {
+ "!child": { # '!' is the reference prefix
+ "model": "models.Child",
+ "filter": {
+ "age": 5
+ }
+ }
+ }
+ }
+
+ # When seeding instances that has 'filter' key,
+ # then use HybridSeeder, otherwise use Seeder.
+ seeder = HybridSeeder(session, ref_prefix='!')
+ seeder.seed(data)
+
+ session.commit() # or seeder.sesssion.commit()
+
+.. note::
+ ``filter`` key is dependent to HybridSeeder in order to perform correctly.
\ No newline at end of file
diff --git a/persistent-seeder.png b/persistent-seeder.png
new file mode 100644
index 0000000..05800e9
Binary files /dev/null and b/persistent-seeder.png differ
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..374b58c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,6 @@
+[build-system]
+requires = [
+ "setuptools>=42",
+ "wheel"
+]
+build-backend = "setuptools.build_meta"
diff --git a/requirements.txt b/requirements.txt
index 670d440..d115369 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,7 @@
-sqlalchemy>=1.4
-pyyaml>=5.4
+SQLAlchemy>=2.0
+PyYAML>=5.4
+coverage>=6.2
+tox
+pytest
+pylint
+autopep8
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..b4cebdd
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,40 @@
+[metadata]
+name = sqlalchemyseed
+version = attr: sqlalchemyseed.__version__
+description = SQLAlchemy Seeder
+long_description = file: README.md
+long_description_content_type = text/markdown
+url = https://github.com/jedymatt/sqlalchemyseed
+author = Jedy Matt Tabasco
+author_email = jedymatt@gmail.com
+license = MIT
+license_files =
+ LICENSE
+classifiers =
+ License :: OSI Approved :: MIT License
+ Programming Language :: Python :: 3.7
+ Programming Language :: Python :: 3.8
+ Programming Language :: Python :: 3.9
+ Programming Language :: Python :: 3.10
+ Programming Language :: Python :: 3.11
+project_urls =
+ Documentation = https://sqlalchemyseed.readthedocs.io/
+ Source = https://github.com/jedymatt/sqlalchemyseed
+ Tracker = https://github.com/jedymatt/sqlalchemyseed/issues
+keywords = sqlalchemy, orm, seed, seeder, json, yaml
+
+[options]
+packages = find:
+package_dir =
+ =src
+install_requires =
+ SQLAlchemy>=2.0
+python_requires = >=3.7
+
+[options.packages.find]
+where = src
+
+[options.extras_require]
+yaml =
+ PyYAML>=5.4
+
diff --git a/setup.py b/setup.py
index b2fab97..1c56672 100644
--- a/setup.py
+++ b/setup.py
@@ -1,53 +1,4 @@
-import re
-
-from setuptools import setup
-
-with open("README.md", encoding="utf-8") as f:
- LONG_DESCRIPTION = f.read()
-
-
-with open('sqlalchemyseed/__init__.py', 'r') as f:
- pattern = r"^__version__ = ['\"]([^'\"]*)['\"]"
- VERSION = re.search(pattern, f.read(), re.MULTILINE).group(1)
-
-
-packages = ['sqlalchemyseed']
-
-install_requires = [
- 'SQLAlchemy>=1.4',
-]
-
-extras_require = {
- 'yaml': ['PyYAML>=5.4']
-}
-
-
-setup(
- name='sqlalchemyseed',
- version=VERSION,
- description='SQLAlchemy seeder.',
- long_description=LONG_DESCRIPTION,
- long_description_content_type='text/markdown',
- url='https://github.com/jedymatt/sqlalchemyseed',
- author='jedymatt',
- author_email='jedymatt@gmail.com',
- license='MIT',
- packages=packages,
- # package_data={'sqlalchemyseed': ['res/*']},
- install_requires=install_requires,
- extras_require=extras_require,
- python_requires='>=3.6',
- project_urls={
- 'Source': 'https://github.com/jedymatt/sqlalchemyseed',
- 'Tracker': 'https://github.com/jedymatt/sqlalchemyseed/issues',
- },
- classifiers=[
- 'License :: OSI Approved :: MIT License',
-
- 'Programming Language :: Python :: 3.6',
- 'Programming Language :: Python :: 3.7',
- 'Programming Language :: Python :: 3.8',
- 'Programming Language :: Python :: 3.9',
- ],
- keywords='sqlalchemy, seed, seeder, json, yaml',
-)
+from setuptools import setup
+
+
+setup()
diff --git a/sqlalchemyseed/__init__.py b/sqlalchemyseed/__init__.py
deleted file mode 100644
index cbbf9bf..0000000
--- a/sqlalchemyseed/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from .seeder import ClassRegistry
-from .seeder import HybridSeeder
-from .seeder import Seeder
-from .loader import load_entities_from_json
-from .loader import load_entities_from_yaml
-
-__version__ = '0.4.2'
-
-
-if __name__ == '__main__':
- pass
\ No newline at end of file
diff --git a/sqlalchemyseed/loader.py b/sqlalchemyseed/loader.py
deleted file mode 100644
index 21941b3..0000000
--- a/sqlalchemyseed/loader.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import json
-import sys
-
-try:
- # relative import
- from . import validator
-except ImportError:
- import validator
-
-
-try:
- import yaml
-except ModuleNotFoundError:
- pass
-
-
-def load_entities_from_json(json_filepath):
- try:
- with open(json_filepath, 'r') as f:
- entities = json.loads(f.read())
- except FileNotFoundError as error:
- raise FileNotFoundError(error)
-
- validator.SchemaValidator.validate(entities)
-
- return entities
-
-
-def load_entities_from_yaml(yaml_filepath):
- if 'yaml' not in sys.modules:
- raise ModuleNotFoundError(
- 'PyYAML is not installed and is required to run this function. '
- 'To use this function, py -m pip install "sqlalchemyseed[yaml]"'
- )
-
- try:
- with open(yaml_filepath, 'r') as f:
- entities = yaml.load(f.read(), Loader=yaml.SafeLoader)
- except FileNotFoundError as error:
- raise FileNotFoundError(error)
-
- validator.SchemaValidator.validate(entities)
-
- return entities
-
-
-if __name__ == '__main__':
- load_entities_from_yaml('tests/res/data.yaml')
diff --git a/sqlalchemyseed/seeder.py b/sqlalchemyseed/seeder.py
deleted file mode 100644
index 12d93d6..0000000
--- a/sqlalchemyseed/seeder.py
+++ /dev/null
@@ -1,235 +0,0 @@
-import importlib
-from inspect import isclass
-
-import sqlalchemy.orm
-from sqlalchemy import inspect
-from sqlalchemy.exc import NoInspectionAvailable
-from sqlalchemy.orm import ColumnProperty, RelationshipProperty
-
-try:
- # relative import
- from . import validator
-except ImportError:
- import validator
-
-
-class ClassRegistry:
- def __init__(self):
- self._classes = {}
-
- def register_class(self, class_path: str):
- try:
- module_name, class_name = class_path.rsplit('.', 1)
- except ValueError:
- raise ValueError('Invalid module or class input format.')
-
- if class_name not in self._classes:
- class_ = getattr(importlib.import_module(module_name), class_name)
-
- try:
- if isclass(class_) and inspect(class_):
- self._classes[class_path] = class_
- else:
- raise TypeError("'{}' is not a class".format(class_name))
- except NoInspectionAvailable:
- raise TypeError(
- "'{}' is an unsupported class".format(class_name))
-
- def __getitem__(self, class_path: str):
- return self._classes[class_path]
-
- @property
- def registered_classes(self):
- return self._classes.values()
-
- def clear(self):
- self._classes.clear()
-
-
-class Seeder:
- def __init__(self, session: sqlalchemy.orm.Session = None):
- self._session = session
- self._class_registry = ClassRegistry()
- self._instances = []
-
- self._required_keys = [
- ('model', 'data')
- ]
-
- @property
- def session(self):
- return self._session
-
- @session.setter
- def session(self, value):
- if not isinstance(value, sqlalchemy.orm.Session):
- raise TypeError("obj type is not 'Session'.")
-
- self._session = value
-
- @property
- def instances(self):
- return self._instances
-
- def seed(self, instance, add_to_session=True):
- # validate
- validator.SchemaValidator.validate(instance)
-
- # clear previously generated objects
- self._instances.clear()
- self._class_registry.clear()
-
- self._pre_seed(instance)
-
- if add_to_session is True:
- self._session.add_all(self.instances)
-
- def _pre_seed(self, instance, parent=None, parent_attr_name=None):
- if isinstance(instance, list):
- for i in instance:
- self._seed(i, parent, parent_attr_name)
- else:
- self._seed(instance, parent, parent_attr_name)
-
- def _seed(self, instance: dict, parent=None, parent_attr_name=None):
- keys = None
- for r_keys in self._required_keys:
- if all(key in instance.keys() for key in r_keys):
- keys = r_keys
- break
-
- if keys is None:
- raise KeyError(
- "'filter' key is not allowed. Use HybridSeeder instead.")
-
- key_is_data = keys[1] == 'data'
-
- class_path = instance[keys[0]]
- self._class_registry.register_class(class_path)
-
- if isinstance(instance[keys[1]], list):
- for value in instance[keys[1]]:
- obj = self.instantiate_obj(
- class_path, value, key_is_data, parent, parent_attr_name)
- # print(obj, parent, parent_attr_name)
- if parent is not None and parent_attr_name is not None:
- attr_ = getattr(parent.__class__, parent_attr_name)
- if isinstance(attr_.property, RelationshipProperty):
- if attr_.property.uselist is True:
- if getattr(parent, parent_attr_name) is None:
- setattr(parent, parent_attr_name, [])
-
- getattr(parent, parent_attr_name).append(obj)
- else:
- setattr(parent, parent_attr_name, obj)
- else:
- setattr(parent, parent_attr_name, obj)
- else:
- if inspect(obj.__class__) and key_is_data is True:
- self._instances.append(obj)
- # check for relationships
- for k, v in value.items():
- if str(k).startswith('!'):
- self._pre_seed(v, obj, k[1:]) # removed prefix
-
- elif isinstance(instance[keys[1]], dict):
- obj = self.instantiate_obj(
- class_path, instance[keys[1]], key_is_data, parent, parent_attr_name)
- # print(parent, parent_attr_name)
- if parent is not None and parent_attr_name is not None:
- attr_ = getattr(parent.__class__, parent_attr_name)
- if isinstance(attr_.property, RelationshipProperty):
- if attr_.property.uselist is True:
- if getattr(parent, parent_attr_name) is None:
- setattr(parent, parent_attr_name, [])
-
- getattr(parent, parent_attr_name).append(obj)
- else:
- setattr(parent, parent_attr_name, obj)
- else:
- setattr(parent, parent_attr_name, obj)
- else:
- if inspect(obj.__class__) and key_is_data is True:
- self._instances.append(obj)
-
- # check for relationships
- for k, v in instance[keys[1]].items():
- # print(k, v)
- if str(k).startswith('!'):
- # print(k)
- self._pre_seed(v, obj, k[1:]) # removed prefix '!'
-
- return instance
-
- def instantiate_obj(self, class_path, kwargs, key_is_data, parent=None, parent_attr_name=None):
- class_ = self._class_registry[class_path]
-
- filtered_kwargs = {k: v for k, v in kwargs.items() if
- not k.startswith('!') and not isinstance(getattr(class_, k), RelationshipProperty)}
-
- if key_is_data is True:
- return class_(**filtered_kwargs)
- else:
- raise KeyError("key is invalid")
-
-
-class HybridSeeder(Seeder):
- def __init__(self, session: sqlalchemy.orm.Session):
- super().__init__(session=session)
- self._required_keys = [
- ('model', 'data'),
- ('model', 'filter')
- ]
-
- def seed(self, instance):
- super().seed(instance, False)
-
- def instantiate_obj(self, class_path, kwargs, key_is_data, parent, parent_attr_name):
- """Instantiates or queries object, or queries ForeignKey
-
- Args:
- class_path (str): Class path
- kwargs ([dict]): Class kwargs
- key_is_data (bool): key is 'data'
- parent (object): parent object
- parent_attr_name (str): parent attribute name
-
- Returns:
- Any: instantiated object or queried oject, or foreign key id
- """
-
- class_ = self._class_registry[class_path]
-
- filtered_kwargs = {k: v for k, v in kwargs.items() if
- not k.startswith('!') and not isinstance(getattr(class_, k), RelationshipProperty)}
-
- if key_is_data is True:
- if parent is not None and parent_attr_name is not None:
- class_attr = getattr(parent.__class__, parent_attr_name)
- if isinstance(class_attr.property, ColumnProperty):
- raise TypeError('invalid class attribute type')
-
- obj = class_(**filtered_kwargs)
- self._session.add(obj)
- # self._session.flush()
- return obj
- else:
- if parent is not None and parent_attr_name is not None:
- class_attr = getattr(parent.__class__, parent_attr_name)
- if isinstance(class_attr.property, ColumnProperty):
- foreign_key = str(
- list(getattr(parent.__class__, parent_attr_name).foreign_keys)[0].column)
- foreign_key_id = self._query_instance_id(
- class_, filtered_kwargs, foreign_key)
- return foreign_key_id
-
- return self._session.query(class_).filter_by(**filtered_kwargs).one()
-
- def _query_instance_id(self, class_, filtered_kwargs, foreign_key):
- # .id should be the foreign key
- arr = foreign_key.rsplit('.')
- column_name = arr[len(arr)-1]
-
- result = self.session.query(
- getattr(class_, column_name)).filter_by(**filtered_kwargs).one()
- return getattr(result, column_name)
diff --git a/sqlalchemyseed/validator.py b/sqlalchemyseed/validator.py
deleted file mode 100644
index c9a78d0..0000000
--- a/sqlalchemyseed/validator.py
+++ /dev/null
@@ -1,99 +0,0 @@
-def __path_str(path: list):
- return '.'.join(path)
-
-
-class __Tree:
- def __init__(self, obj=None):
- self.obj = obj
-
- self._current_path = []
-
- def walk(self, obj=None):
- if obj is None:
- obj = self.obj
- self._walk(obj)
-
- def _walk(self, obj):
- # convert list into dict
- if isinstance(obj, list):
- obj = {str(key): value for key, value in enumerate(obj)}
-
- if isinstance(obj, dict):
- for key, value in obj.items():
- self._current_path.append(key)
- # print(f"\'{path_str(self.path)}\'", '=', value)
-
- if isinstance(value, list) or isinstance(value, dict):
- # print(f"\'{path_str(self.path)}\'", '=', value)
- self._walk(value)
- else:
- # if leaf
- # print(f"\'{self.current_path}\'", '=', value)
- pass
- self._current_path.pop()
- else:
- return print(obj)
-
-
-class SchemaValidator:
- # root_type = dict
- # root_length = 2
- _required_keys = (
- ('model', 'data'),
- ('model', 'filter')
- )
- _model_type = str
- _entity_types = [dict, list]
-
- @classmethod
- def validate(cls, obj):
- if isinstance(obj, list):
- for i in obj:
- cls.validate(i)
- else:
- cls._validate(obj)
-
- @classmethod
- def _validate(cls, obj):
- if not isinstance(obj, dict):
- raise TypeError('\'obj\' object is not type \'dict\'.')
-
- if len(obj) > 2:
- raise ValueError('obj length exceeds to \'2\'')
- # elif len(obj) < 2:
- # raise ValueError('obj length lesser than \'2\'')
- elif len(obj) == 0:
- return
-
- obj_keys = None
- for keys in cls._required_keys:
- if all(key in obj.keys() for key in keys):
- obj_keys = keys
- break
-
- if obj_keys is None:
- raise KeyError('keys not accepted')
-
- if not isinstance(obj[obj_keys[0]], cls._model_type):
- raise TypeError(f'obj[{obj_keys[0]}] is not type \'str\'')
- if type(obj[obj_keys[1]]) not in cls._entity_types:
- raise KeyError(
- f'obj[{obj_keys[1]}] is not type \'dict\' or \'list\'')
- # print(obj_keys[1], '=', obj[obj_keys[1]])
- if isinstance(obj[obj_keys[1]], list):
- if len(obj[obj_keys[1]]) == 0:
- raise ValueError(f'obj[{obj_keys[1]}]: value is empty')
- elif not all(isinstance(item, dict) for item in obj[obj_keys[1]]):
- raise TypeError(
- f'\'obj[{obj_keys[1]}]\': items is not type \'dict\'')
- else:
- for items in obj[obj_keys[1]]:
- for k, v in items.items():
- if str(k).startswith('!'):
- cls.validate(v)
- elif isinstance(obj[obj_keys[1]], dict):
- # print(obj_keys[1], '=', obj[obj_keys[1]])
- for k, v in obj[obj_keys[1]].items():
- # print(f'{k}, {v}')
- if str(k).startswith('!'):
- cls.validate(v)
diff --git a/src/sqlalchemyseed/__init__.py b/src/sqlalchemyseed/__init__.py
new file mode 100644
index 0000000..96341f8
--- /dev/null
+++ b/src/sqlalchemyseed/__init__.py
@@ -0,0 +1,17 @@
+"""
+SQLAlchemy seeder that supports nested relationships with an easy to read text files.
+"""
+
+from .seeder import HybridSeeder
+from .seeder import Seeder
+from .loader import load_entities_from_json
+from .loader import load_entities_from_yaml
+from .loader import load_entities_from_csv
+from . import util
+from . import attribute
+
+
+__version__ = "2.0.0"
+
+if __name__ == '__main__':
+ pass
diff --git a/src/sqlalchemyseed/_future/__init__.py b/src/sqlalchemyseed/_future/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/sqlalchemyseed/_future/seeder.py b/src/sqlalchemyseed/_future/seeder.py
new file mode 100644
index 0000000..307f202
--- /dev/null
+++ b/src/sqlalchemyseed/_future/seeder.py
@@ -0,0 +1,23 @@
+"""
+MIT License
+
+Copyright (c) 2021 Jedy Matt Tabasco
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
diff --git a/src/sqlalchemyseed/attribute.py b/src/sqlalchemyseed/attribute.py
new file mode 100644
index 0000000..0691a3f
--- /dev/null
+++ b/src/sqlalchemyseed/attribute.py
@@ -0,0 +1,70 @@
+"""
+attribute module containing helper functions for instrumented attribute.
+"""
+
+from functools import lru_cache
+from inspect import isclass
+
+from sqlalchemy.orm import ColumnProperty, RelationshipProperty
+from sqlalchemy.orm.attributes import InstrumentedAttribute, get_attribute, set_attribute
+
+
+def instrumented_attribute(class_or_instance, key: str):
+ """
+ Returns instrumented attribute from the class or instance.
+ """
+
+ if isclass(class_or_instance):
+ return getattr(class_or_instance, key)
+
+ return getattr(class_or_instance.__class__, key)
+
+
+def attr_is_relationship(instrumented_attr: InstrumentedAttribute):
+ """
+ Check if instrumented attribute property is a RelationshipProperty
+ """
+ return isinstance(instrumented_attr.property, RelationshipProperty)
+
+
+def attr_is_column(instrumented_attr: InstrumentedAttribute):
+ """
+ Check if instrumented attribute property is a ColumnProperty
+ """
+ return isinstance(instrumented_attr.property, ColumnProperty)
+
+
+def set_instance_attribute(instance, key, value):
+ """
+ Set attribute value of instance
+ """
+
+ instr_attr: InstrumentedAttribute = getattr(instance.__class__, key)
+
+ if attr_is_relationship(instr_attr) and instr_attr.property.uselist:
+ get_attribute(instance, key).append(value)
+ else:
+ set_attribute(instance, key, value)
+
+@lru_cache()
+def foreign_key_column(instrumented_attr: InstrumentedAttribute):
+ """
+ Returns the table name of the first foreignkey.
+ """
+ return next(iter(instrumented_attr.foreign_keys)).column
+
+@lru_cache()
+def referenced_class(instrumented_attr: InstrumentedAttribute):
+ """
+ Returns class that the attribute is referenced to.
+ """
+
+ if attr_is_relationship(instrumented_attr):
+ return instrumented_attr.mapper.class_
+
+ table_name = foreign_key_column(instrumented_attr).table.name
+
+ return next(filter(
+ lambda mapper: mapper.class_.__tablename__ == table_name,
+ instrumented_attr.parent.registry.mappers
+ )).class_
diff --git a/src/sqlalchemyseed/constants.py b/src/sqlalchemyseed/constants.py
new file mode 100644
index 0000000..020c878
--- /dev/null
+++ b/src/sqlalchemyseed/constants.py
@@ -0,0 +1,4 @@
+MODEL_KEY = 'model'
+DATA_KEY = 'data'
+FILTER_KEY = 'filter'
+SOURCE_KEYS = [DATA_KEY, FILTER_KEY]
diff --git a/src/sqlalchemyseed/dynamic_seeder.py b/src/sqlalchemyseed/dynamic_seeder.py
new file mode 100644
index 0000000..77f63f5
--- /dev/null
+++ b/src/sqlalchemyseed/dynamic_seeder.py
@@ -0,0 +1,4 @@
+class DynamicSeeder:
+ """
+ DynamicSeeder class
+ """
diff --git a/src/sqlalchemyseed/errors.py b/src/sqlalchemyseed/errors.py
new file mode 100644
index 0000000..e1baa3b
--- /dev/null
+++ b/src/sqlalchemyseed/errors.py
@@ -0,0 +1,38 @@
+class ClassNotFoundError(Exception):
+ """Raised when the class is not found"""
+
+
+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"""
+
+
+class EmptyDataError(Exception):
+ """Raised when data is empty"""
+
+
+class InvalidKeyError(Exception):
+ """Raised when an invalid key is invoked"""
+
+
+class ParseError(Exception):
+ """Raised when parsing string fails"""
+
+
+class UnsupportedClassError(Exception):
+ """Raised when an unsupported class is invoked"""
+
+
+class NotInModuleError(Exception):
+ """Raised when a value is not found in module"""
+
+
+class InvalidModelPath(Exception):
+ """Raised when an invalid model path is invoked"""
diff --git a/src/sqlalchemyseed/json.py b/src/sqlalchemyseed/json.py
new file mode 100644
index 0000000..34b51c0
--- /dev/null
+++ b/src/sqlalchemyseed/json.py
@@ -0,0 +1,168 @@
+from typing import Callable, List, Union
+
+
+class JsonWalker:
+ """
+ JsonWalker class
+ """
+
+ def __init__(self, json: Union[list, dict] = None) -> None:
+ self.path = []
+ self.root = json
+ self._current = json
+
+ @property
+ def json(self):
+ """
+ Returns current json
+ """
+ return self._current
+
+ def keys(self):
+ """
+ Returns list of keys either str or int
+ """
+ if self.json_is_dict:
+ return self._current.keys()
+
+ if self.json_is_list:
+ return list(map(lambda index: index, range(len(self._current))))
+
+ return []
+
+ @property
+ def current_key(self) -> Union[int, str]:
+ """
+ Returns the key of the current json
+ """
+ return self.path[-1]
+
+ def forward(self, keys: List[Union[int, str]]):
+ """
+ Move and replace current json forward.
+ Returns current json.
+ """
+
+ if len(keys) == 0:
+ return self._current
+
+ self._current = self.find_from_current(keys)
+ self.path.extend(keys)
+ return self._current
+
+ def backward(self):
+ """
+ Revert current json to its parent.
+ Returns reverted current json
+ """
+ if len(self.path) == 0:
+ raise ValueError('No parent found error')
+
+ self._current = self.find_from_root(self.path[:-1])
+ self.path.pop()
+
+ def find_from_current(self, keys: List[Union[int, str]]):
+ """
+ Find item from current json that correlates list of keys
+ """
+ return self._find(self._current, keys)
+
+ def _find(self, json: Union[list, dict], keys: List[Union[int, str]]):
+ """
+ Recursive call of finding item
+ """
+ return self._find(json[keys[0]], keys[1:]) if keys else json
+
+ def find_from_root(self, keys: List[Union[int, str]]):
+ """
+ Find item from the root json that correlates list of keys
+ """
+ return self._find(self.root, keys)
+
+ def reset(self, root=None):
+ """
+ Resets to initial state.
+ If root argument is supplied, self.root will be replaced.
+ """
+ if root is not None:
+ self.root = root
+
+ self._current = self.root
+ self.path.clear()
+
+ def exec_func_iter(self, func: Callable):
+ """
+ Executes function when iterating
+ """
+ current = self._current
+ if self.json_is_dict:
+ for key in current.keys():
+ self.forward([key])
+ func()
+ self.backward()
+ elif self.json_is_list:
+ for index in range(len(current)):
+ self.forward([index])
+ func()
+ self.backward()
+ else:
+ func()
+
+ def iter_as_list(self):
+ """
+ Iterates current as list.
+ Yields index and value.
+
+ Raises TypeError if current json is not list
+ """
+ if not self.json_is_list:
+ raise TypeError('json is not list')
+
+ current = self._current
+ for index, value in enumerate(current):
+ self.forward([index])
+ yield index, value
+ self.backward()
+
+ def iter_as_dict_items(self):
+ """
+ Iterates current as dict.
+ Yields key and value.
+
+ Raises TypeError if current json is not dict
+ """
+ if not self.json_is_dict:
+ raise TypeError('json is not dict')
+
+ current = self._current
+ for key, value in current.items():
+ self.forward([key])
+ yield key, value
+ self.backward()
+
+ @property
+ def json_is_dict(self):
+ """
+ Returns true if current json is dict
+ """
+ return isinstance(self._current, dict)
+
+ @property
+ def json_is_list(self):
+ """
+ Returns true if current json is list
+ """
+ return isinstance(self._current, list)
+
+
+def sort_json(json: Union[list, dict], reverse=False):
+ """
+ Sort json function
+ """
+ if isinstance(json, list):
+ return sorted(sorted(sort_json(item), reverse=reverse) for item in json)
+
+ if isinstance(json, dict):
+ return {key: sort_json(value, reverse=reverse) for key, value in json.items()}
+
+ return json
diff --git a/src/sqlalchemyseed/loader.py b/src/sqlalchemyseed/loader.py
new file mode 100644
index 0000000..064cd00
--- /dev/null
+++ b/src/sqlalchemyseed/loader.py
@@ -0,0 +1,64 @@
+"""
+Text file loader module
+"""
+
+import csv
+import json
+import sys
+
+try:
+ import yaml
+except ModuleNotFoundError: # pragma: no cover
+ pass
+
+
+def load_entities_from_json(json_filepath) -> dict:
+ """
+ Get entities from json
+ """
+ try:
+ with open(json_filepath, 'r', encoding='utf-8') as file:
+ entities = json.loads(file.read())
+ except FileNotFoundError as error:
+ raise FileNotFoundError from error
+
+ return entities
+
+
+def load_entities_from_yaml(yaml_filepath):
+ """
+ Get entities from yaml
+ """
+ if 'yaml' not in sys.modules:
+ raise ModuleNotFoundError(
+ 'PyYAML is not installed and is required to run this function. '
+ 'To use this function, py -m pip install "sqlalchemyseed[yaml]"'
+ )
+
+ try:
+ with open(yaml_filepath, 'r', encoding='utf-8') as file:
+ entities = yaml.load(file.read(), Loader=yaml.SafeLoader)
+ except FileNotFoundError as error:
+ raise FileNotFoundError from error
+
+ return entities
+
+
+def load_entities_from_csv(csv_filepath: str, model) -> dict:
+ """Load entities from csv file
+
+ :param csv_filepath: string csv file path
+ :param model: either str or class
+ :return: dict of entities
+ """
+ with open(csv_filepath, 'r', encoding='utf-8') as file:
+ source_data = list(
+ map(dict, csv.DictReader(file, skipinitialspace=True)))
+ if isinstance(model, str):
+ model_name = model
+ else:
+ model_name = '.'.join([model.__module__, model.__name__])
+
+ entities = {'model': model_name, 'data': source_data}
+
+ return entities
diff --git a/sqlalchemyseed/res/schema.json b/src/sqlalchemyseed/res/schema.json
similarity index 100%
rename from sqlalchemyseed/res/schema.json
rename to src/sqlalchemyseed/res/schema.json
diff --git a/src/sqlalchemyseed/seeder.py b/src/sqlalchemyseed/seeder.py
new file mode 100644
index 0000000..d3d34c1
--- /dev/null
+++ b/src/sqlalchemyseed/seeder.py
@@ -0,0 +1,316 @@
+"""
+Seeder module
+"""
+
+import abc
+from typing import NamedTuple, Union
+
+import sqlalchemy
+
+
+from . import errors, util, validator
+from .attribute import (attr_is_column, attr_is_relationship, foreign_key_column, instrumented_attribute,
+ referenced_class, set_instance_attribute)
+from .constants import DATA_KEY, MODEL_KEY, SOURCE_KEYS
+from .json import JsonWalker
+
+
+class AbstractSeeder(abc.ABC):
+ """
+ AbstractSeeder class
+ """
+
+ @property
+ @abc.abstractmethod
+ def instances(self):
+ """
+ Seeded instances
+ """
+
+ @abc.abstractmethod
+ def seed(self, entities):
+ """
+ Seed data
+ """
+
+ @abc.abstractmethod
+ def _pre_seed(self, *args, **kwargs):
+ """
+ Pre-seeding phase
+ """
+
+ @abc.abstractmethod
+ def _seed(self, *args, **kwargs):
+ """
+ Seeding phase
+ """
+
+ @abc.abstractmethod
+ def _seed_children(self, *args, **kwargs):
+ """
+ Seed children
+ """
+
+
+class InstanceAttributeTuple(NamedTuple):
+ """
+ Instrance and attribute name tuple
+ """
+ instance: object
+ attr_name: str
+
+
+def filter_kwargs(kwargs: dict, class_, ref_prefix):
+ """
+ Filters kwargs
+ """
+ return {
+ k: v for k, v in util.iter_non_ref_kwargs(kwargs, ref_prefix)
+ if not attr_is_relationship(instrumented_attribute(class_, str(k)))
+ }
+
+
+class Seeder:
+ """
+ Basic Seeder class
+ """
+
+ def __init__(self, session: sqlalchemy.orm.Session = None, ref_prefix="!"):
+ self.session = session
+ self.ref_prefix = ref_prefix
+
+ self._instances: list = []
+ self._walker: JsonWalker = JsonWalker()
+ self._current_parent: InstanceAttributeTuple = None
+
+ @property
+ def instances(self) -> tuple:
+ """
+ Returns instances of the seeded entities
+ """
+ return tuple(self._instances)
+
+ def _model_class(self):
+ """
+ Returns class from class path or referenced class
+ """
+ if MODEL_KEY in self._walker.json:
+ class_path = self._walker.json[MODEL_KEY]
+ return util.get_model_class(class_path)
+
+ # Expects parent is not None
+ instr_attr = getattr(
+ self._current_parent.instance.__class__,
+ self._current_parent.attr_name
+ )
+ return referenced_class(instr_attr)
+
+ def seed(self, entities: Union[list, dict], add_to_session=True):
+ """
+ Seed method
+ """
+ validator.validate(entities=entities, ref_prefix=self.ref_prefix)
+
+ self._instances.clear()
+
+ self._walker.reset(root=entities)
+ self._current_parent = None
+
+ self._pre_seed()
+
+ if add_to_session:
+ self.session.add_all(self.instances)
+
+ def _pre_seed(self):
+ # iterates current json as list
+ # expected json value is [{'model': ...}, ...] or {'model': ...}
+
+ if self._walker.json_is_list:
+ for index in range(len(self._walker.json)):
+ self._walker.forward([index])
+ self._seed()
+ self._walker.backward()
+
+ elif self._walker.json_is_dict:
+ self._seed()
+
+ self._current_parent = None
+
+ def _seed(self):
+ # expected json value is {'model': ..., 'data': ...}
+ class_ = self._model_class()
+
+ # moves json.current to json.current[self.__data_key]
+ # expected json value is [{'value':...}]
+ self._walker.forward([DATA_KEY])
+ # iterate json.current as list
+
+ # @lru_cache()
+ def init_item():
+ kwargs = self._walker.json
+ filtered_kwargs = filter_kwargs(kwargs, class_, self.ref_prefix)
+ instance = class_(**filtered_kwargs)
+
+ if self._current_parent is not None:
+ set_instance_attribute(
+ self._current_parent.instance, self._current_parent.attr_name, instance
+ )
+ else:
+ self._instances.append(instance)
+
+ self._seed_children(instance)
+
+ if self._walker.json_is_list:
+ for index in range(len(self._walker.json)):
+ self._walker.forward([index])
+ init_item()
+ self._walker.backward()
+ else:
+ init_item()
+
+ self._walker.backward()
+
+ def _seed_children(self, instance):
+ # expected json is dict:
+ # {'model': ...}
+ def seed_child():
+ key = self._walker.current_key
+ if key.startswith(self.ref_prefix):
+ attr_name = key[len(self.ref_prefix):]
+ self._current_parent = InstanceAttributeTuple(
+ instance, attr_name)
+ self._pre_seed()
+
+ self._walker.exec_func_iter(seed_child)
+
+
+class HybridSeeder(AbstractSeeder):
+ """
+ HybridSeeder class. Accepts 'filter' key for referencing children.
+ """
+
+ def __init__(self, session: sqlalchemy.orm.Session, ref_prefix: str = '!'):
+ self.session = session
+ self._instances = []
+ self.ref_prefix = ref_prefix
+ self._walker = JsonWalker()
+ self._parent = None
+
+ @property
+ def instances(self):
+ return tuple(self._instances)
+
+ def get_model_class(self, entity, parent: InstanceAttributeTuple):
+ # if self.__model_key in entity and (parent is not None and parent.is_column_attribute()):
+ # raise errors.InvalidKeyError("column attribute does not accept 'model' key")
+
+ if MODEL_KEY in entity:
+ class_path = entity[MODEL_KEY]
+ return util.get_model_class(class_path)
+
+ # parent is not None
+ return referenced_class(instrumented_attribute(parent.instance, parent.attr_name))
+
+ def seed(self, entities):
+ validator.hybrid_validate(
+ entities=entities, ref_prefix=self.ref_prefix
+ )
+
+ self._instances.clear()
+ self._walker.reset(root=entities)
+ self._parent = None
+
+ self._pre_seed(entities)
+
+ def _pre_seed(self, entity, parent=None):
+ if isinstance(entity, dict):
+ self._seed(entity, parent)
+ else: # is list
+ for item in entity:
+ self._pre_seed(item, parent)
+
+ def _seed(self, entity, parent):
+ class_ = self.get_model_class(entity, parent)
+
+ source_key = next(
+ filter(lambda sk: sk in entity, SOURCE_KEYS)
+ )
+
+ source_data = entity[source_key]
+
+ # source_data is list
+ if isinstance(source_data, list):
+ for kwargs in source_data:
+ instance = self._setup_instance(
+ class_, kwargs, source_key, parent)
+ self._seed_children(instance, kwargs)
+ return
+
+ # source_data is dict
+ instance = self._setup_instance(
+ class_, source_data, source_key, parent)
+ self._seed_children(instance, source_data)
+
+ def _seed_children(self, instance, kwargs):
+ for attr_name, value in util.iter_ref_kwargs(kwargs, self.ref_prefix):
+ self._pre_seed(
+ entity=value, parent=InstanceAttributeTuple(instance, attr_name))
+
+ def _setup_instance(self, class_, kwargs: dict, key: str, parent: InstanceAttributeTuple):
+ filtered_kwargs = filter_kwargs(kwargs, class_, self.ref_prefix)
+
+ if key == DATA_KEY:
+ instance = self._setup_data_instance(
+ class_, filtered_kwargs, parent)
+ else: # key == key.filter()
+ # instance = self.session.query(class_).filter_by(**filtered_kwargs)
+ instance = self._setup_filter_instance(
+ class_, filtered_kwargs, parent
+ )
+
+ # setting parent
+ if parent is not None:
+ set_instance_attribute(parent.instance, parent.attr_name, instance)
+
+ return instance
+
+ def _setup_data_instance(self, class_, filtered_kwargs, parent: InstanceAttributeTuple):
+ if parent is not None and attr_is_column(instrumented_attribute(parent.instance, parent.attr_name)):
+ raise errors.InvalidKeyError(
+ "'data' key is invalid for a column attribute."
+ )
+
+ instance = class_(**filtered_kwargs)
+
+ if parent is None:
+ self.session.add(instance)
+ self._instances.append(instance)
+
+ return instance
+
+ def _setup_filter_instance(self, class_, filtered_kwargs, parent: InstanceAttributeTuple):
+ if parent is not None:
+ instr_attr = instrumented_attribute(
+ parent.instance, parent.attr_name)
+ else:
+ instr_attr = None
+
+ if instr_attr is not None and attr_is_column(instr_attr):
+ column = foreign_key_column(instr_attr)
+ return self.session.query(column).filter_by(**filtered_kwargs).one()[0]
+
+ if instr_attr is not None and attr_is_relationship(instr_attr):
+ return self.session.query(referenced_class(instr_attr)).filter_by(
+ **filtered_kwargs
+ ).one()
+
+ return self.session.query(class_).filter_by(**filtered_kwargs).one()
+
+
+class DynamicSeeder:
+ """
+ DynamicSeeder class
+ """
+
+ def __init__(self):
+ pass
diff --git a/src/sqlalchemyseed/util.py b/src/sqlalchemyseed/util.py
new file mode 100644
index 0000000..a54dbbf
--- /dev/null
+++ b/src/sqlalchemyseed/util.py
@@ -0,0 +1,114 @@
+"""
+Utility functions
+"""
+
+
+from functools import lru_cache
+import importlib
+from typing import Iterable
+
+from sqlalchemy import inspect
+from sqlalchemyseed import errors
+
+
+def iter_ref_kwargs(kwargs: dict, ref_prefix: str):
+ """
+ Iterate kwargs with name prefix or references
+ """
+ for attr_name, value in kwargs.items():
+ if attr_name.startswith(ref_prefix):
+ # removed prefix
+ yield attr_name[len(ref_prefix):], value
+
+
+def iter_kwargs_with_prefix(kwargs: dict, prefix: str):
+ """
+ Iterate kwargs(dict) that has the specified prefix.
+ """
+ for key, value in kwargs.items():
+ if str(key).startswith(prefix):
+ yield key, value
+
+
+def iterate_json(json: dict, key_prefix: str):
+ """
+ Iterate through json that has matching key prefix
+ """
+ for key, value in json.items():
+ has_prefix = str(key).startswith(key_prefix)
+
+ if has_prefix:
+ # removed prefix
+ yield key[len(key_prefix):], value
+
+
+def iterate_json_no_prefix(json: dict, key_prefix: str):
+ """
+ Iterate through json that has no matching key prefix
+ """
+ for key, value in json.items():
+ has_prefix = str(key).startswith(key_prefix)
+ if not has_prefix:
+ yield key, value
+
+
+def iter_non_ref_kwargs(kwargs: dict, ref_prefix: str):
+ """Iterate kwargs, skipping item with name prefix or references"""
+ for attr_name, value in kwargs.items():
+ if not attr_name.startswith(ref_prefix):
+ yield attr_name, value
+
+
+def is_supported_class(class_):
+ """
+ Check if it is a class and supports sqlalchemy
+ """
+ insp = inspect(class_, raiseerr=False)
+ # insp.is_mapper means it is a mapped class
+ return insp is not None and insp.is_mapper
+
+
+def generate_repr(instance: object) -> str:
+ """
+ Generate repr of object instance
+ """
+ class_name = instance.__class__.__name__
+ insp = inspect(instance)
+ attributes = {column.key: column.value for column in insp.attrs}
+ str_attributes = ",".join(f"{k}='{v}'" for k, v in attributes.items())
+ return f"<{class_name}({str_attributes})>"
+
+
+def find_item(json: Iterable, keys: list):
+ """
+ Finds item of json from keys
+ """
+ return find_item(json[keys[0]], keys[1:]) if keys else json
+
+
+# check if class is a sqlalchemy model
+def is_model(class_):
+ """
+ Check if class is a sqlalchemy model
+ """
+ insp = inspect(class_, raiseerr=False)
+ return insp is not None and insp.is_mapper
+
+
+# get sqlalchemy model class from path
+@lru_cache(maxsize=None)
+def get_model_class(path: str):
+ """
+ Get sqlalchemy model class from path
+ """
+ try:
+ module_name, class_name = path.rsplit(".", 1)
+ module = importlib.import_module(module_name)
+ except (ImportError, AttributeError) as e:
+ raise errors.InvalidModelPath(path=path, error=e)
+
+ class_ = getattr(module, class_name)
+ if not is_model(class_):
+ raise errors.UnsupportedClassError(path=path)
+
+ return class_
diff --git a/src/sqlalchemyseed/validator.py b/src/sqlalchemyseed/validator.py
new file mode 100644
index 0000000..5316f6c
--- /dev/null
+++ b/src/sqlalchemyseed/validator.py
@@ -0,0 +1,143 @@
+"""
+Validator module.
+"""
+
+from . import errors, util
+
+
+class Key:
+ def __init__(self, name: str, type_):
+ self.name = name
+ self.type_ = type_
+
+ @classmethod
+ def model(cls):
+ return cls('model', str)
+
+ @classmethod
+ def data(cls):
+ return cls('data', dict)
+
+ @classmethod
+ def filter(cls):
+ return cls('filter', dict)
+
+ def is_valid_type(self, entity):
+ return isinstance(entity, self.type_)
+
+ def __str__(self):
+ return self.name
+
+ def __eq__(self, o: object) -> bool:
+ if isinstance(o, self.__class__):
+ return self.name == o.name and self.type_ == o.type_
+
+ if isinstance(o, str):
+ return self.name == o
+
+ return False
+
+ def __hash__(self):
+ return hash(self.name)
+
+
+def check_model_key(entity: dict, entity_is_parent: bool):
+ model = Key.model()
+ if model not in entity and entity_is_parent:
+ raise errors.MissingKeyError("'model' key is missing.")
+ # check type_
+ if model in entity and not model.is_valid_type(entity[model]):
+ 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_source_key(entity: dict, source_keys: list) -> Key:
+ source_key: Key = next(
+ (sk for sk in source_keys if sk in entity),
+ None
+ )
+
+ # check if current keys has at least, data or filter key
+ if source_key is None:
+ raise errors.MissingKeyError(
+ f"Missing {', '.join(map(str, source_keys))} key(s).")
+
+ return source_key
+
+
+def check_source_data(source_data, source_key: Key):
+ if not isinstance(source_data, dict) and not isinstance(source_data, list):
+ raise errors.InvalidTypeError(
+ f"Invalid type_, {str(source_key)} should be either 'dict' or 'list'.")
+
+ if isinstance(source_data, list) and len(source_data) == 0:
+ raise errors.EmptyDataError(
+ "Empty list, 'data' or 'filter' list should not be empty.")
+
+
+def check_data_type(item, source_key: Key):
+ if not source_key.is_valid_type(item):
+ raise errors.InvalidTypeError(
+ f"Invalid type_, '{source_key.name}' should be '{source_key.type_}'")
+
+
+class SchemaValidator:
+
+ def __init__(self, source_keys, ref_prefix):
+ self._source_keys = source_keys
+ self._ref_prefix = ref_prefix
+
+ def validate(self, entities):
+ self._pre_validate(entities, entity_is_parent=True)
+
+ 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
+ if isinstance(entities, dict):
+ 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_model_key(entity, entity_is_parent)
+
+ # get source key, either data or filter key
+ source_key = check_source_key(entity, self._source_keys)
+ source_data = entity[source_key]
+
+ check_source_data(source_data, source_key)
+
+ if isinstance(source_data, list):
+ for item in source_data:
+ check_data_type(item, source_key)
+ # check if item is a relationship attribute
+ self.check_attributes(item)
+ else:
+ # source_data is dict
+ # check if item is a relationship attribute
+ self.check_attributes(source_data)
+
+ def check_attributes(self, source_data: dict):
+ for _, value in util.iter_ref_kwargs(source_data, self._ref_prefix):
+ self._pre_validate(value, entity_is_parent=False)
+
+
+def validate(entities, ref_prefix='!'):
+
+ SchemaValidator(source_keys=[Key.data()], ref_prefix=ref_prefix) \
+ .validate(entities=entities)
+
+
+def hybrid_validate(entities, ref_prefix='!'):
+
+ SchemaValidator(source_keys=[Key.data(), Key.filter()], ref_prefix=ref_prefix) \
+ .validate(entities=entities)
diff --git a/tests/_variables.py b/tests/_variables.py
new file mode 100644
index 0000000..cfdaf62
--- /dev/null
+++ b/tests/_variables.py
@@ -0,0 +1,4 @@
+from tests.models import Single
+
+
+SINGLE = Single()
diff --git a/tests/constants.py b/tests/constants.py
new file mode 100644
index 0000000..0b53ea2
--- /dev/null
+++ b/tests/constants.py
@@ -0,0 +1,6 @@
+SINGLE = {
+ 'model': 'models.Single',
+ 'data': {
+ 'value': 'Single Value'
+ }
+}
diff --git a/tests/instances.py b/tests/instances.py
new file mode 100644
index 0000000..5f38599
--- /dev/null
+++ b/tests/instances.py
@@ -0,0 +1,451 @@
+PARENT = {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'My Company'
+ }
+}
+
+PARENT_INVALID = 'str is not valid type for parent'
+
+PARENT_EMPTY = []
+
+PARENT_EMPTY_DATA_LIST_INVALID = {
+ 'model': 'tests.models.Company',
+ 'data': []
+}
+
+PARENT_MISSING_MODEL_INVALID = {
+ 'data': {
+
+ }
+}
+
+PARENT_INVALID_MODEL_INVALID = {
+ 'model': 9_999
+}
+
+PARENT_WITH_EXTRA_LENGTH_INVALID = {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'My Company'
+ },
+ 'extra': 'extra value'
+}
+
+PARENT_WITH_EMPTY_DATA = {
+ 'model': 'tests.models.Company',
+ 'data': {}
+}
+
+PARENT_WITHOUT_DATA_INVALID = {
+ 'model': 'tests.models.Company'
+}
+
+PARENT_WITH_MULTI_DATA = {
+ 'model': 'tests.models.Company',
+ 'data': [
+ {
+ 'name': 'My Company'
+ },
+ {
+ 'name': 'Second Company'
+ }
+ ]
+}
+
+PARENT_WITH_DATA_AND_INVALID_DATA_INVALID = {
+ 'model': 'tests.models.Company',
+ 'data': [
+ {},
+ 9_999_999
+ ]
+}
+
+PARENT_WITH_INVALID_DATA_INVALID = {
+ 'model': 'tests.models.Company',
+ 'data': 'str is an invalid type of \'data\''
+}
+
+PARENTS = [
+ {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'My Company'
+ }
+ },
+ {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'Another Company'
+ }
+ }
+]
+
+PARENTS_WITH_EMPTY_DATA = [
+ {
+ 'model': 'tests.models.Company',
+ 'data': {}
+ },
+ {
+ 'model': 'tests.models.Company',
+ 'data': {}
+ }
+]
+
+PARENTS_WITHOUT_DATA_INVALID = [
+ {
+ 'model': 'tests.models.Company'
+ },
+ {
+ 'model': 'tests.models.Company'
+ }
+]
+
+PARENTS_WITH_MULTI_DATA = [
+ {
+ 'model': 'tests.models.Company',
+ 'data': [
+ {
+ 'name': 'My Company'
+ },
+ {
+ 'name': 'Second Company'
+ }
+ ]
+ },
+ {
+ 'model': 'tests.models.Company',
+ 'data': [
+ {
+ 'name': 'Third Company'
+ },
+ {
+ 'name': 'Fourth Company'
+ }
+ ]
+ }
+]
+
+PARENT_TO_CHILD = {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'Juan Dela Cruz',
+ '!company': {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'Juan Company'
+ }
+ }
+ }
+}
+
+PARENT_TO_CHILD_WITHOUT_PREFIX_INVALID = {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'Juan Dela Cruz',
+ 'company': {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'Juan Company'
+ }
+ }
+ }
+}
+
+PARENT_TO_CHILD_WITHOUT_CHILD_MODEL = {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'Juan Dela Cruz',
+ '!company': {
+ 'data': {
+ 'name': 'Juan Company'
+ }
+ }
+ }
+}
+
+PARENT_TO_CHILDREN = {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'My Company',
+ '!employees': [
+ {
+ 'model': 'tests.models.Employee',
+ 'data':
+ {
+ 'name': 'John Smith'
+ }
+ },
+ {
+ 'model': 'tests.models.Employee',
+ 'data':
+ {
+ 'name': 'Juan Dela Cruz'
+ }
+ }
+
+ ]
+ }
+}
+
+PARENT_TO_CHILDREN_WITHOUT_MODEL = {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'My Company',
+ '!employees': [
+ {
+ 'data':
+ {
+ 'name': 'John Smith'
+ }
+ },
+ {
+ 'data':
+ {
+ 'name': 'Juan Dela Cruz'
+ }
+ }
+
+ ]
+ }
+}
+
+PARENT_TO_CHILDREN_WITH_MULTI_DATA = {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'My Company',
+ '!employees': {
+ 'model': 'tests.models.Employee',
+ 'data': [
+ {
+ 'name': 'John Smith'
+ },
+ {
+ 'name': 'Juan Dela Cruz'
+ }
+ ]
+ }
+ }
+}
+
+PARENT_TO_CHILDREN_WITH_MULTI_DATA_WITHOUT_MODEL = {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'My Company',
+ '!employees': {
+ 'data': [
+ {
+ 'name': 'John Smith'
+ },
+ {
+ 'name': 'Juan Dela Cruz'
+ }
+ ]
+ }
+ }
+}
+
+HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_COLUMN_NO_MODEL = [
+ {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'Init Company'
+ }
+ },
+ {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'John March',
+ '!company_id': {
+ 'filter': {
+ 'name': 'Init Company'
+ }
+ }
+ }
+ }
+]
+
+HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_COLUMN = [
+ {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'Init Company'
+ }
+ },
+ {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'John March',
+ '!company_id': {
+ 'model': 'tests.models.Company',
+ 'filter': {
+ 'name': 'Init Company'
+ }
+ }
+ }
+ }
+]
+
+HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_RELATIONSHIP_NO_MODEL = [
+ {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'Init Company'
+ }
+ },
+ {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'John March',
+ '!company': {
+ 'filter': {
+ 'name': 'Init Company'
+ }
+ }
+ }
+ }
+]
+
+HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_RELATIONSHIP = [
+ {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'Init Company'
+ }
+ },
+ {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'John March',
+ '!company': {
+ 'model': 'tests.models.Company',
+ 'filter': {
+ 'name': 'Init Company'
+ }
+ }
+ }
+ }
+]
+
+#
+# HYBRID_SEED_PARENT_TO_ANOTHER_CHILD_WITH_REF_ATTRIBUTE_NO_MODEL = [
+#
+# {
+# 'model': 'tests.models.Employee',
+# 'data': {
+# 'name': 'John March',
+# 'company_id': {
+# 'filter': {
+# 'name': 'Init Company'
+# }
+# }
+# }
+# }
+# ]
+
+instance = {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'MyCompany',
+ '!employees': {
+ 'model': 'tests.models.Employee',
+ 'data': [
+ {
+ 'name': 'John Smith'
+ },
+ {
+ 'name': 'Juan Dela Cruz'
+ }
+ ]
+ }
+ }
+}
+
+instance = {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'name': 'MyCompany',
+ '!employees': {
+ 'data': [
+ {
+ 'name': 'John Smith'
+ },
+ {
+ 'name': 'Juan Dela Cruz'
+ }
+ ]
+ }
+ }
+}
+
+instance = {
+ 'model': 'tests.models.Company',
+ 'data': [
+ {
+ 'name': 'MyCompany',
+ '!employees': {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'John Smith'
+ }
+
+ }
+ },
+ {
+ 'name': 'MySecondCompany'
+ },
+ ]
+}
+
+instance = {
+ 'model': 'tests.models.Company',
+ 'data': [
+ {
+ 'name': 'Shader',
+ },
+ {
+ 'name': 'One'
+ }
+ ]
+}
+
+instance = {
+ 'model': 'tests.models.Employee',
+ 'data': {
+ 'name': 'Juan',
+ '!company': {
+ 'model': 'tests.models.Company',
+ 'data': {
+ 'Juan\'s Company'
+ }
+ }
+ }
+}
+
+instance = [
+ {
+ "model": "tests.models.Company",
+ "data": {
+ "name": "Mike Corporation",
+ "!employees": {
+ "model": "tests.models.Employee",
+ "data": {
+ }
+ }
+ }
+ },
+ {
+ "model": "tests.models.Company",
+ "data": [
+ {
+
+ }
+ ]
+ },
+ {
+ "model": "tests.models.Company",
+ "data": {
+
+ }
+ }
+]
diff --git a/tests/models.py b/tests/models.py
index c70383b..7e4448c 100644
--- a/tests/models.py
+++ b/tests/models.py
@@ -1,9 +1,9 @@
-from sqlalchemy import Integer, Column, String, ForeignKey
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import relationship
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import relationship, declarative_base
+from sqlalchemyseed.util import generate_repr
Base = declarative_base()
-
+AnotherBase = declarative_base()
class Company(Base):
__tablename__ = 'companies'
@@ -45,17 +45,133 @@ def __repr__(self) -> str:
class Child(Base):
__tablename__ = 'children'
-
+
id = Column(Integer, primary_key=True)
name = Column(String(255))
parent_id = Column(Integer, ForeignKey('parents.id'))
-
+
children = relationship('GrandChild')
-
-
+
+
class GrandChild(Base):
__tablename__ = 'grand_children'
-
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String(255))
+ parent_id = Column(Integer, ForeignKey('children.id'))
+
+
+class Person(Base):
+ __tablename__ = 'persons'
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String(50))
+
+
+not_class = 'this is not a class'
+
+
+class UnsupportedClass:
+ """This is an example of an unsupported class"""
+ pass
+
+
+class AnotherCompany(AnotherBase):
+ __tablename__ = 'companies'
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String(255), unique=True)
+
+ employees = relationship('Employee', back_populates='company')
+
+ def __repr__(self) -> str:
+ return f""
+
+
+class AnotherEmployee(AnotherBase):
+ __tablename__ = 'employees'
+
id = Column(Integer, primary_key=True)
name = Column(String(255))
- parent_id = Column(Integer, ForeignKey('children.id'))
\ No newline at end of file
+
+ company_id = Column(Integer, ForeignKey('companies.id'))
+
+ company = relationship(
+ 'Company', back_populates='employees', uselist=False)
+
+ def __repr__(self) -> str:
+ return f""
+
+
+class Single(Base):
+ """
+ Single class with no child
+ """
+ __tablename__ = 'single'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+
+ def __repr__(self) -> str:
+ return generate_repr(self)
+
+
+class One(Base):
+ """
+ One class with no other Parent that relates to its child.
+ """
+ __tablename__ = 'single_parent'
+
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+
+ def __repr__(self) -> str:
+ return generate_repr(self)
+
+
+class OneToOne(Base):
+ """
+ OneToOne class
+ """
+ __tablename__ = 'one_to_one'
+
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+
+ def __repr__(self) -> str:
+ return generate_repr(self)
+
+class OneToMany(Base):
+ """
+ OneToMany class
+ """
+ __tablename__ = 'one_to_many'
+
+ id = Column(Integer, primary_key=True)
+
+ def __repr__(self) -> str:
+ return generate_repr(self)
+
+
+class ManyToOne(Base):
+ """
+ ManyToOne class
+ """
+ __tablename__ = 'many_to_one'
+
+ id = Column(Integer, primary_key=True)
+
+
+class ManyToMany(Base):
+ """
+ ManyToMany class
+ """
+ __tablename__ = 'many_to_many'
+
+ id = Column(Integer, primary_key=True)
+
+ def __repr__(self) -> str:
+ return generate_repr(self,)
+
+
+val = Single(value='str value')
+print(repr(val))
diff --git a/tests/relationships/__init__.py b/tests/relationships/__init__.py
new file mode 100644
index 0000000..3fdfc33
--- /dev/null
+++ b/tests/relationships/__init__.py
@@ -0,0 +1 @@
+from . import association_object, many_to_many, one_to_many, one_to_one, many_to_one
diff --git a/tests/relationships/association_object.py b/tests/relationships/association_object.py
new file mode 100644
index 0000000..5fefb62
--- /dev/null
+++ b/tests/relationships/association_object.py
@@ -0,0 +1,27 @@
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import relationship, declarative_base
+
+Base = declarative_base()
+
+
+class Association(Base):
+ __tablename__ = 'association'
+ left_id = Column(ForeignKey('left.id'), primary_key=True)
+ right_id = Column(ForeignKey('right.id'), primary_key=True)
+ extra_value = Column(String(45))
+ child = relationship("Child", back_populates="parents")
+ parent = relationship("Parent", back_populates="children")
+
+
+class Parent(Base):
+ __tablename__ = 'left'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+ children = relationship("Association", back_populates="parent")
+
+
+class Child(Base):
+ __tablename__ = 'right'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+ parents = relationship("Association", back_populates="child")
diff --git a/tests/relationships/many_to_many.py b/tests/relationships/many_to_many.py
new file mode 100644
index 0000000..4400552
--- /dev/null
+++ b/tests/relationships/many_to_many.py
@@ -0,0 +1,35 @@
+from sqlalchemy import Column, ForeignKey, Integer, Table
+from sqlalchemy.orm import relationship, declarative_base
+from sqlalchemy.sql.sqltypes import String
+
+Base = declarative_base()
+
+
+association_table = Table(
+ 'association',
+ Base.metadata,
+ Column('left_id', ForeignKey('left.id'), primary_key=True),
+ Column('right_id', ForeignKey('right.id'), primary_key=True),
+)
+
+
+class Parent(Base):
+ __tablename__ = 'left'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+ children = relationship(
+ "Child",
+ secondary=association_table,
+ back_populates="parents"
+ )
+
+
+class Child(Base):
+ __tablename__ = 'right'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+ parents = relationship(
+ "Parent",
+ secondary=association_table,
+ back_populates="children"
+ )
diff --git a/tests/relationships/many_to_one.py b/tests/relationships/many_to_one.py
new file mode 100644
index 0000000..c456b77
--- /dev/null
+++ b/tests/relationships/many_to_one.py
@@ -0,0 +1,20 @@
+from sqlalchemy import Column, ForeignKey, Integer
+from sqlalchemy.orm import relationship, declarative_base
+from sqlalchemy.sql.sqltypes import String
+
+Base = declarative_base()
+
+
+class Parent(Base):
+ __tablename__ = 'parent'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+ child_id = Column(Integer, ForeignKey('child.id'))
+ child = relationship("Child", back_populates="parents")
+
+
+class Child(Base):
+ __tablename__ = 'child'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+ parents = relationship("Parent", back_populates="child")
diff --git a/tests/relationships/one_to_many.py b/tests/relationships/one_to_many.py
new file mode 100644
index 0000000..6ada27d
--- /dev/null
+++ b/tests/relationships/one_to_many.py
@@ -0,0 +1,19 @@
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import relationship, declarative_base
+
+Base = declarative_base()
+
+
+class Parent(Base):
+ __tablename__ = 'parent'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+ children = relationship("Child", back_populates="parent")
+
+
+class Child(Base):
+ __tablename__ = 'child'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+ parent_id = Column(Integer, ForeignKey('parent.id'))
+ parent = relationship("Parent", back_populates="children")
diff --git a/tests/relationships/one_to_one.py b/tests/relationships/one_to_one.py
new file mode 100644
index 0000000..3751b3d
--- /dev/null
+++ b/tests/relationships/one_to_one.py
@@ -0,0 +1,22 @@
+from sqlalchemy import Column, ForeignKey, Integer
+from sqlalchemy.orm import relationship, declarative_base
+from sqlalchemy.sql.sqltypes import String
+
+Base = declarative_base()
+
+
+class Parent(Base):
+ __tablename__ = 'parent'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+
+ child = relationship("Child", back_populates="parent", uselist=False)
+
+
+class Child(Base):
+ __tablename__ = 'child'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+
+ parent_id = Column(Integer, ForeignKey('parent.id'))
+ parent = relationship("Parent", back_populates="child")
diff --git a/tests/res/companies.csv b/tests/res/companies.csv
new file mode 100644
index 0000000..0d6be99
--- /dev/null
+++ b/tests/res/companies.csv
@@ -0,0 +1,4 @@
+name
+MyCompany
+Mike
+March
\ No newline at end of file
diff --git a/tests/scratch.py b/tests/scratch.py
new file mode 100644
index 0000000..b53db11
--- /dev/null
+++ b/tests/scratch.py
@@ -0,0 +1,84 @@
+"""
+Scratch file
+"""
+
+import dataclasses
+from typing import Generic, NewType, Type, TypeVar, Union
+from sqlalchemy import Column, Integer, String, create_engine, types
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import ColumnProperty, relationship, sessionmaker
+from sqlalchemy.orm import MapperProperty
+from sqlalchemy.orm import attributes
+from sqlalchemy.orm.attributes import ScalarAttributeImpl, get_attribute, set_committed_value
+from sqlalchemy.orm.base import state_attribute_str
+from sqlalchemy.sql.schema import ForeignKey
+from sqlalchemy import inspect
+from sqlalchemy.orm.mapper import Mapper, class_mapper
+from sqlalchemy.orm import object_mapper
+
+import sqlalchemyseed
+from sqlalchemyseed import *
+from sqlalchemyseed.key_value import *
+from sqlalchemyseed.util import generate_repr
+from dataclasses import dataclass
+from sqlalchemy.orm.instrumentation import ClassManager
+
+Base = declarative_base()
+
+
+class Single(Base):
+ """
+ A class with no child
+ """
+ __tablename__ = 'single'
+ id = Column(Integer, primary_key=True)
+ value = Column(String(45))
+
+ def __repr__(self) -> str:
+ return generate_repr(self)
+
+
+class Parent(Base):
+ __tablename__ = 'parents'
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String(255))
+ children = relationship('Child')
+
+ def __repr__(self) -> str:
+ return generate_repr(self)
+
+
+class Child(Base):
+ __tablename__ = 'children'
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String(255))
+ parent_id = Column(Integer, ForeignKey('parents.id'))
+
+ def __repr__(self) -> str:
+ return generate_repr(self)
+
+
+engine = create_engine('sqlite://')
+Session = sessionmaker(bind=engine)
+Base.metadata.create_all(engine)
+
+print(sqlalchemyseed.__version__)
+
+single = Single(value='str')
+
+wrapper = AttributeWrapper(getattr(single, 'value'))
+print(wrapper.is_column)
+mapper: Mapper = object_mapper(single)
+class_manager: ClassManager = mapper.class_manager
+
+attr = get_attribute(single, 'value')
+for c in list(mapper.attrs):
+ c: ColumnProperty = c
+ print(c.key)
+ parent: Mapper = c.parent
+ print(parent.class_.__name__)
+ var: InstrumentedAttribute = c.class_attribute
+
+InstrumentedList
\ No newline at end of file
diff --git a/tests/test_json.py b/tests/test_json.py
new file mode 100644
index 0000000..2647892
--- /dev/null
+++ b/tests/test_json.py
@@ -0,0 +1,58 @@
+import unittest
+
+from sqlalchemyseed.json import JsonWalker
+
+
+class TestJsonWalker(unittest.TestCase):
+ """
+ TestJsonWalker class
+ """
+
+ def setUp(self) -> None:
+ self.walker = JsonWalker()
+
+ def test_forward(self):
+ """
+ Test JsonWalker.forward
+ """
+
+ json = {
+ 'key': {
+ 'key_1': 'value_1',
+ 'arr': [
+ 0,
+ 1,
+ 2
+ ]
+ }
+ }
+
+ self.walker.reset(json)
+ self.walker.forward(['key', 'key_1'])
+ expected_value = json['key']['key_1']
+ self.assertEqual(self.walker.json, expected_value)
+
+ def test_backward(self):
+ """
+ Test JsonWalker.backward
+ """
+
+ json = \
+ {
+ 'a': {
+ 'aa': {
+ 'aaa': 'value'
+ }
+ }
+ }
+
+ self.walker.reset(json)
+ self.walker.forward(['a', 'aa', 'aaa'])
+ self.walker.backward()
+ self.assertEqual(self.walker.json, json['a']['aa'])
+ self.walker.backward()
+ self.assertEqual(self.walker.json, json['a'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 34db20d..53da7ca 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -1,15 +1,41 @@
import unittest
+from sqlalchemyseed import loader
-from sqlalchemyseed import load_entities_from_json, load_entities_from_yaml
-
+from src.sqlalchemyseed import load_entities_from_json
+from src.sqlalchemyseed import load_entities_from_yaml
+from src.sqlalchemyseed import load_entities_from_csv
class TestLoader(unittest.TestCase):
def test_load_entities_from_json(self):
entities = load_entities_from_json('tests/res/data.json')
-
self.assertEqual(len(entities), 6)
+ def test_load_entities_from_json_file_not_found(self):
+ self.assertRaises(FileNotFoundError,
+ lambda: load_entities_from_json('tests/res/non-existent-file'))
+
def test_load_entities_from_yaml(self):
entities = load_entities_from_yaml('tests/res/data.yml')
- print(entities)
self.assertEqual(len(entities), 2)
+
+ def test_load_entities_from_yaml_file_not_found(self):
+ self.assertRaises(FileNotFoundError,
+ lambda: load_entities_from_yaml('tests/res/non-existent-file'))
+
+ def test_load_entities_from_csv_input_class(self):
+ from tests.models import Company
+ entities = load_entities_from_csv(
+ 'tests/res/companies.csv', Company)
+ self.assertEqual(len(entities['data']), 3)
+
+ def test_load_entities_from_csv_input_model_string(self):
+ self.assertIsNotNone(load_entities_from_csv(
+ 'tests/res/companies.csv', "tests.models.Company"))
+
+ def test_loader_yaml_not_installed(self):
+ loader.sys.modules.pop('yaml')
+ self.assertRaises(
+ ModuleNotFoundError,
+ lambda: load_entities_from_yaml('tests/res/data.yml')
+ )
+
\ No newline at end of file
diff --git a/tests/test_seeder.py b/tests/test_seeder.py
index 5354f9d..21d4c36 100644
--- a/tests/test_seeder.py
+++ b/tests/test_seeder.py
@@ -1,107 +1,389 @@
import unittest
+from typing import List
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
-from sqlalchemyseed import ClassRegistry, HybridSeeder, Seeder
+from sqlalchemyseed import HybridSeeder, Seeder, errors
+from tests import instances as ins
from tests.models import Base, Company
+from tests.relationships import association_object, many_to_many, many_to_one, one_to_many, one_to_one
-engine = create_engine('sqlite://')
-Session = sessionmaker(bind=engine)
-Base.metadata.create_all(engine)
+class TestSeederRelationship(unittest.TestCase):
+ """
+ TestSeederRelationship class for testing Seeder class dealing with relationships.
+ """
-class TestClassRegistry(unittest.TestCase):
- def test_get_invalid_item(self):
- class_registry = ClassRegistry()
- self.assertRaises(KeyError, lambda: class_registry['InvalidClass'])
+ def setUp(self) -> None:
- def test_register_class(self):
- cr = ClassRegistry()
- cr.register_class('tests.models.Company')
- from tests.models import Company
- self.assertIs(cr['tests.models.Company'], Company)
+ self.engine = create_engine('sqlite://')
+ Session = sessionmaker( # pylint: disable=invalid-name
+ bind=self.engine
+ )
+ session = Session()
+ self.seeder = Seeder(session)
+ self.base = None
+ def tearDown(self) -> None:
+ self.base.metadata.drop_all(self.engine)
+ self.base = None
-class TestSeeder(unittest.TestCase):
- def test_seed(self):
- instance = {
- 'model': 'tests.models.Company',
- 'data': {
- 'name': 'MyCompany',
- '!employees': {
- 'model': 'tests.models.Employee',
- 'data': [
+ def test_seed_one_to_many(self):
+ """
+ Test seed one to many relationship
+ """
+
+ self.base = one_to_many.Base
+ self.base.metadata.create_all(self.engine)
+
+ module_path = 'tests.relationships.one_to_many'
+
+ json = \
+ {
+ 'model': f'{module_path}.Parent',
+ 'data': {
+ 'value': 'parent_1',
+ '!children': [
{
- 'name': 'John Smith'
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_1'
+ },
},
{
- 'name': 'Juan Dela Cruz'
- }
- ]
- }
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_2'
+ },
+ },
+ ],
+ },
}
- }
+ self.seeder.seed(json)
- with Session() as session:
- seeder = Seeder(session=session)
- seeder.seed(instance)
- self.assertEqual(len(seeder.instances), 1)
+ # seeder.instances should only contain the first level entities
+ self.assertEqual(len(self.seeder.instances), 1)
- def test_seed_no_relationship(self):
- instance = {
- 'model': 'tests.models.Company',
- 'data': [
+ # assign classes to remove module
+ Parent = one_to_many.Parent
+ Child = one_to_many.Child
+
+ parent: Parent = self.seeder.instances[0]
+ children: List[Child] = parent.children
+
+ self.assertEqual(parent.value, 'parent_1')
+ self.assertEqual(len(children), 2)
+
+ self.assertEqual(children[0].value, 'child_1')
+ self.assertEqual(children[0].parent, parent)
+
+ self.assertEqual(children[1].value, 'child_2')
+ self.assertEqual(children[1].parent, parent)
+
+ def test_seed_many_to_one(self):
+ """
+ Test seed many to one relationship
+ """
+
+ self.base = many_to_one.Base
+ self.base.metadata.create_all(self.engine)
+
+ module_path = 'tests.relationships.many_to_one'
+
+ json = \
+ [
{
- 'name': 'Shader',
+ 'model': f'{module_path}.Parent',
+ 'data': {
+ 'value': 'parent_1',
+ '!child': {
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_1'
+ }
+ }
+ }
},
{
- 'name': 'One'
+ 'model': f'{module_path}.Parent',
+ 'data': {
+ 'value': 'parent_2',
+ '!child': {
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_2'
+ }
+ }
+ }
}
]
- }
- seeder = Seeder()
- # self.assertIsNone(seeder.seed(instance))
- seeder.seed(instance, False)
- self.assertEqual(len(seeder.instances), 2)
+ self.seeder.seed(json)
- def test_seed_multiple_entities(self):
- instance = [
+ Parent = many_to_one.Parent
+
+ self.assertEqual(len(self.seeder.instances), 2)
+
+ parents: List[Parent] = self.seeder.instances
+
+ parent_1 = parents[0]
+ self.assertEqual(parent_1.value, 'parent_1')
+ self.assertEqual(parent_1.child.value, 'child_1')
+ self.assertEqual(parent_1.child.parents, [parent_1])
+
+ parent_2 = parents[1]
+ self.assertEqual(parent_2.value, 'parent_2')
+ self.assertEqual(parent_2.child.value, 'child_2')
+ self.assertEqual(parent_2.child.parents, [parent_2])
+
+ def test_seed_one_to_one(self):
+ """
+ Test seed one to one relationship
+ """
+
+ self.base = one_to_one.Base
+ self.base.metadata.create_all(self.engine)
+
+ module_path = 'tests.relationships.one_to_one'
+
+ json = \
{
- "model": "tests.models.Company",
- "data": {
- "name": "Mike Corporation",
- "!employees": {
- "model": "tests.models.Employee",
- "data": {
+ 'model': f'{module_path}.Parent',
+ 'data': {
+ 'value': 'parent_1',
+ '!child': {
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_1'
}
}
}
- },
- {
- "model": "tests.models.Company",
- "data": [
- {
+ }
+
+ self.seeder.seed(json)
+
+ self.assertEqual(len(self.seeder.instances), 1)
+
+ parent = self.seeder.instances[0]
+ child = parent.child
+
+ self.assertEqual(parent.value, 'parent_1')
+ self.assertEqual(child.value, 'child_1')
+
+ self.assertEqual(child.parent, parent)
+ def test_seed_many_to_many(self):
+ """
+ Test seed many to many relationship
+ """
+
+ self.base = many_to_many.Base
+ self.base.metadata.create_all(self.engine)
+
+ module_path = 'tests.relationships.many_to_many'
+
+ json = \
+ [
+ {
+ 'model': f'{module_path}.Parent',
+ 'data': {
+ 'value': 'parent_1',
+ '!children': [
+ {
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_1'
+ }
+ },
+ {
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_2'
+ }
+ }
+ ]
}
- ]
- },
- {
- "model": "tests.models.Company",
- "data": {
+ },
+ {
+ 'model': f'{module_path}.Parent',
+ 'data': {
+ 'value': 'parent_2',
+ '!children': [
+ {
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_3'
+ }
+ },
+ {
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_4'
+ }
+ }
+ ]
+ }
+ }
+ ]
+ self.seeder.seed(json)
+
+ self.assertEqual(len(self.seeder.instances), 2)
+
+ parents = self.seeder.instances
+
+ parents: List[many_to_many.Parent] = self.seeder.instances
+
+ self.assertEqual(len(parents), 2)
+
+ # parent 1
+ parent_1: many_to_many.Parent = parents[0]
+ self.assertEqual(parent_1.value, 'parent_1')
+
+ parent_1_children: List[many_to_many.Child] = parent_1.children
+ self.assertEqual(len(parent_1_children), 2)
+
+ child_1 = parent_1_children[0]
+ self.assertEqual(child_1.value, 'child_1')
+ self.assertEqual(child_1.parents, [parent_1])
+
+ child_2 = parent_1_children[1]
+ self.assertEqual(child_2.value, 'child_2')
+ self.assertEqual(child_2.parents, [parent_1])
+ # parent 2
+ parent_2: many_to_many.Parent = parents[1]
+ self.assertEqual(parent_2.value, 'parent_2')
+
+ parent_2_children: List[many_to_many.Child] = parent_2.children
+ self.assertEqual(len(parent_2_children), 2)
+
+ child_3 = parent_2_children[0]
+ self.assertEqual(child_3.value, 'child_3')
+ self.assertEqual(child_3.parents, [parent_2])
+
+ child_4 = parent_2_children[1]
+ self.assertEqual(child_4.value, 'child_4')
+ self.assertEqual(child_4.parents, [parent_2])
+
+ def test_seed_association_object(self):
+ """
+ Test seed association object relationship
+ """
+
+ self.base = many_to_many.Base
+ self.base.metadata.create_all(self.engine)
+
+ module_path = 'tests.relationships.association_object'
+
+ json = \
+ {
+ 'model': f'{module_path}.Parent',
+ 'data': {
+ 'value': 'parent_1',
+ '!children': [
+ {
+ 'model': f'{module_path}.Association',
+ 'data': {
+ 'extra_value': 'association_1',
+ '!child': {
+ 'model': f'{module_path}.Child',
+ 'data': {
+ 'value': 'child_1'
+ }
+ }
+ }
+ }
+ ]
}
}
- ]
- with Session() as session:
- seeder = Seeder(session)
- seeder.seed(instance, False)
- self.assertEqual(len(seeder.instances), 3)
+ self.seeder.seed(json)
+
+ self.assertEqual(len(self.seeder.instances), 1)
+
+ parent: association_object.Parent = self.seeder.instances[0]
+ self.assertEqual(parent.value, 'parent_1')
+
+ self.assertEqual(len(parent.children), 1)
+ association: association_object.Association = parent.children[0]
+ self.assertEqual(association.extra_value, 'association_1')
+ self.assertEqual(association.parent, parent)
+ self.assertIsNotNone(association.child)
+
+ child: association_object.Child = association.child
+ self.assertEqual(child.value, 'child_1')
+ self.assertEqual(child.parents[0], association)
+
+
+class TestSeeder(unittest.TestCase):
+ """
+ Test class for Seeder class
+ """
+
+ def setUp(self) -> None:
+ self.engine = create_engine('sqlite://')
+ self.Session = sessionmaker( # pylint: disable=invalid-name
+ bind=self.engine,
+ )
+ self.session = self.Session()
+ self.seeder = Seeder(self.session)
+ Base.metadata.create_all(self.engine)
+
+ def tearDown(self) -> None:
+ Base.metadata.drop_all(self.engine)
+
+ def test_seed_parent(self):
+ self.assertIsNone(self.seeder.seed(ins.PARENT))
+
+ def test_seed_parent_add_to_session_false(self):
+ self.assertIsNone(self.seeder.seed(ins.PARENT, add_to_session=False))
+
+ def test_seed_parent_with_multi_data(self):
+ self.assertIsNone(self.seeder.seed(ins.PARENT_WITH_MULTI_DATA))
+
+ def test_seed_parents(self):
+ self.assertIsNone(self.seeder.seed(ins.PARENTS))
+
+ def test_seed_parents_with_empty_data(self):
+ self.assertIsNone(self.seeder.seed(ins.PARENTS_WITH_EMPTY_DATA))
+
+ def test_seed_parents_with_multi_data(self):
+ self.assertIsNone(self.seeder.seed(ins.PARENTS_WITH_MULTI_DATA))
+
+ def test_seed_parent_to_child(self):
+ self.assertIsNone(self.seeder.seed(ins.PARENT_TO_CHILD))
+
+ def test_seed_parent_to_children(self):
+ self.assertIsNone(self.seeder.seed(ins.PARENT_TO_CHILDREN))
+
+ def test_seed_parent_to_children_without_model(self):
+ self.assertIsNone(self.seeder.seed(
+ ins.PARENT_TO_CHILDREN_WITHOUT_MODEL))
+
+ def test_seed_parent_to_children_with_multi_data(self):
+ self.assertIsNone(self.seeder.seed(
+ ins.PARENT_TO_CHILDREN_WITH_MULTI_DATA))
+
+ def test_seed_parent_to_child_without_child_model(self):
+ self.assertIsNone(self.seeder.seed(
+ ins.PARENT_TO_CHILD_WITHOUT_CHILD_MODEL))
+
+ def test_seed_parent_to_children_with_multi_data_without_model(self):
+ self.assertIsNone(self.seeder.seed(
+ ins.PARENT_TO_CHILDREN_WITH_MULTI_DATA_WITHOUT_MODEL))
class TestHybridSeeder(unittest.TestCase):
+ def setUp(self) -> None:
+ self.engine = create_engine('sqlite://')
+ self.Session = sessionmaker(
+ bind=self.engine) # pylint: disable=invalid-name
+ Base.metadata.create_all(self.engine)
+
+ def tearDown(self) -> None:
+ Base.metadata.drop_all(self.engine)
+
def test_hybrid_seed_with_relationship(self):
instance = [
{
@@ -129,7 +411,7 @@ def test_hybrid_seed_with_relationship(self):
}
}]
- with Session() as session:
+ with self.Session() as session:
seeder = HybridSeeder(session)
seeder.seed(instance)
self.assertEqual(len(seeder.instances), 3)
@@ -149,7 +431,6 @@ def test_filter_with_foreign_key(self):
{
'name': 'John Smith',
'!company_id': {
- 'model': 'tests.models.Company',
'filter': {
'name': 'MyCompany'
}
@@ -158,7 +439,6 @@ def test_filter_with_foreign_key(self):
{
'name': 'Juan Dela Cruz',
'!company_id': {
- 'model': 'tests.models.Company',
'filter': {
'name': 'MyCompany'
}
@@ -168,7 +448,7 @@ def test_filter_with_foreign_key(self):
},
]
- with Session() as session:
+ with self.Session() as session:
seeder = HybridSeeder(session)
seeder.seed(instance)
self.assertEqual(len(seeder.instances), 3)
@@ -182,7 +462,7 @@ def test_no_data_key_field(self):
}
]
- with Session() as session:
+ with self.Session() as session:
session.add(
Company(name='MyCompany')
)
@@ -215,10 +495,10 @@ def test_seed_nested_relationships(self):
}
}
- with Session() as session:
+ with self.Session() as session:
seeder = HybridSeeder(session)
seeder.seed(instance)
- print(seeder.instances[0].children[0].children)
+ # print(seeder.instances[0].children[0].children)
self.assertEqual(
seeder.instances[0].children[0].children[0].name, "Alice Smith")
@@ -228,7 +508,7 @@ def test_foreign_key_data_instead_of_filter(self):
'data': {
'name': 'John Smith',
'!company_id': {
- 'model': 'tests.models.Company',
+ 'model': 'tests.models.Company',
'data': {
'name': 'MyCompany'
}
@@ -237,10 +517,80 @@ def test_foreign_key_data_instead_of_filter(self):
}
- with Session() as session:
+ with self.Session() as session:
+ seeder = HybridSeeder(session)
+ self.assertRaises(errors.InvalidKeyError,
+ lambda: seeder.seed(instance))
+
+ def test_hybrid_seed_parent_to_child_with_ref_attribute(self):
+ with self.Session() as session:
+ seeder = HybridSeeder(session)
+ seeder.seed(ins.HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_COLUMN)
+ employee = seeder.instances[1]
+ self.assertIsNotNone(employee.company)
+
+ def test_hybrid_seed_parent_to_child_with_ref_attribute_no_model(self):
+ with self.Session() as session:
+ seeder = HybridSeeder(session)
+ self.assertIsNone(seeder.seed(
+ ins.HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_COLUMN_NO_MODEL))
+ # print(session.new, session.dirty)
+
+ def test_hybrid_seed_parent_to_child_with_ref_attribute_relationship(self):
+ with self.Session() as session:
+ seeder = HybridSeeder(session)
+ self.assertIsNone(seeder.seed(
+ ins.HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_RELATIONSHIP))
+ # print(session.new, session.dirty)
+
+ def test_hybrid_seed_parent_to_child_with_ref_relationship_no_model(self):
+ with self.Session() as session:
seeder = HybridSeeder(session)
- self.assertRaises(TypeError, lambda: seeder.seed(instance))
+ self.assertIsNone(seeder.seed(
+ ins.HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_RELATIONSHIP_NO_MODEL))
+ # print(session.new, session.dirty)
+
+
+class TestSeederCostumizedPrefix(unittest.TestCase):
+ def setUp(self) -> None:
+ self.engine = create_engine('sqlite://')
+ self.Session = sessionmaker(bind=self.engine)
+ Base.metadata.create_all(self.engine)
+
+ def test_seeder_parent_to_child(self):
+ import json
+ custom_instance = json.dumps(ins.PARENT_TO_CHILD)
+ custom_instance = custom_instance.replace('!', '@')
+ custom_instance = json.loads(custom_instance)
+
+ with self.Session() as session:
+ seeder = Seeder(session, ref_prefix='@')
+ seeder.seed(custom_instance)
+ employee = seeder.instances[0]
+ self.assertIsNotNone(employee.company)
+
+ def test_hybrid_seeder_parent_to_child_with_ref_column(self):
+ import json
+ custom_instance = json.dumps(
+ ins.HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_COLUMN)
+ custom_instance = custom_instance.replace('!', '@')
+ custom_instance = json.loads(custom_instance)
+
+ with self.Session() as session:
+ seeder = HybridSeeder(session, ref_prefix='@')
+ seeder.seed(custom_instance)
+ employee = seeder.instances[1]
+ self.assertIsNotNone(employee.company)
+ def test_hybrid_seeder_parent_to_child_with_ref_relationship(self):
+ import json
+ custom_instance = json.dumps(
+ ins.HYBRID_SEED_PARENT_TO_CHILD_WITH_REF_RELATIONSHIP)
+ custom_instance = custom_instance.replace('!', '@')
+ custom_instance = json.loads(custom_instance)
-if __name__ == '__main__':
- unittest.main()
+ with self.Session() as session:
+ seeder = HybridSeeder(session, ref_prefix='@')
+ seeder.seed(custom_instance)
+ employee = seeder.instances[1]
+ self.assertIsNotNone(employee.company)
diff --git a/tests/test_temp_seeder.py b/tests/test_temp_seeder.py
new file mode 100644
index 0000000..4b69b29
--- /dev/null
+++ b/tests/test_temp_seeder.py
@@ -0,0 +1,10 @@
+import unittest
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from sqlalchemyseed import HybridSeeder, Seeder
+from tests import instances as ins
+from tests.models import Base
+
+
diff --git a/tests/test_validator.py b/tests/test_validator.py
index e92e8d5..e5c3adc 100644
--- a/tests/test_validator.py
+++ b/tests/test_validator.py
@@ -1,137 +1,87 @@
import unittest
+from sqlalchemyseed import validator
-from sqlalchemyseed.validator import SchemaValidator
+from src.sqlalchemyseed import errors
+from src.sqlalchemyseed.validator import SchemaValidator, Key, hybrid_validate
+from tests import instances as ins
class TestSchemaValidator(unittest.TestCase):
+ def setUp(self) -> None:
+ self.source_keys = [Key.data()]
- def test_valid_empty_entity(self):
- instance = [
-
- ]
- self.assertIsNone(SchemaValidator.validate(instance))
-
- def test_valid_empty_entities(self):
- instance = [
- {}
- ]
- self.assertIsNone(SchemaValidator.validate(instance))
-
- def test_valid_entity_with_empty_args(self):
- instance = {
- 'model': 'models.Company',
- 'data': {
-
- }
- }
- self.assertIsNone(SchemaValidator.validate(instance))
-
- def test_valid_entity_with_args(self):
- instance = {
- 'model': 'models.Company',
- 'data': {
- 'name': 'Company Name'
- }
- }
-
- self.assertIsNone(SchemaValidator.validate(instance))
-
- def test_valid_entities_with_empty_args(self):
- instance = [
- {
- 'model': 'models.Company',
- 'data': {
-
- }
- },
- {
- 'model': 'models.Company',
- 'data': {
-
- }
- }
- ]
-
- self.assertIsNone(SchemaValidator.validate(instance))
-
- def test_entity_with_relationship(self):
- instance = [
- {
- 'model': 'models.Company',
- 'data': {
- '!employees': {
- 'model': 'models.Employee',
- 'data': {
-
- }
- }
- }
- },
- ]
-
- self.assertIsNone(SchemaValidator.validate(instance))
-
- def test_valid_entity_relationships(self):
- instance = [
- {
- 'model': 'models.Company',
- 'data': {
- '!employees': {
- 'model': 'models.Employee',
- 'data': {
-
- }
- }
- }
- },
- ]
-
- self.assertIsNone(SchemaValidator.validate(instance))
-
- def test_invalid_entity_with_empty_relationships(self):
- instance = [
- {
- 'model': 'models.Company',
- 'data':
- {
- '!employees': {
- 'model': 'models.Employee',
- 'data': [
-
- ]
- }
- }
-
- },
- ]
- self.assertRaises(ValueError, lambda: SchemaValidator.validate(instance))
-
- def test_valid_empty_relationships_list(self):
- instance = [
- {
- 'model': 'models.Company',
- 'data':
- {
- '!employees': []
- }
- },
- ]
-
- self.assertIsNone(SchemaValidator.validate(instance))
-
- def test_valid_empty_relationships_dict(self):
- instance = [
- {
- 'model': 'models.Company',
- 'data':
- {
- '!employees': {}
- }
- },
- ]
-
- self.assertIsNone(SchemaValidator.validate(instance))
-
-
-if __name__ == '__main__':
- unittest.main()
+ def test_parent(self):
+ self.assertIsNone(hybrid_validate(ins.PARENT))
+
+ def test_parent_invalid(self):
+ with self.assertRaises(errors.InvalidTypeError):
+ hybrid_validate(ins.PARENT_INVALID)
+
+ def test_parent_empty(self):
+ self.assertIsNone(hybrid_validate(ins.PARENT_EMPTY))
+
+ def test_parent_empty_data_list_invalid(self):
+ with self.assertRaises(errors.EmptyDataError):
+ hybrid_validate(ins.PARENT_EMPTY_DATA_LIST_INVALID)
+
+ def test_parent_missing_model_invalid(self):
+ with self.assertRaises(errors.MissingKeyError):
+ hybrid_validate(ins.PARENT_MISSING_MODEL_INVALID)
+
+ def test_parent_invalid_model_invalid(self):
+ with self.assertRaises(errors.InvalidTypeError):
+ hybrid_validate(ins.PARENT_INVALID_MODEL_INVALID)
+
+ def test_parent_with_extra_length_invalid(self):
+ with self.assertRaises(errors.MaxLengthExceededError):
+ hybrid_validate(ins.PARENT_WITH_EXTRA_LENGTH_INVALID)
+
+ def test_parent_with_empty_data(self):
+ self.assertIsNone(hybrid_validate(ins.PARENT_WITH_EMPTY_DATA))
+
+ def test_parent_with_multi_data(self):
+ self.assertIsNone(hybrid_validate(ins.PARENT_WITH_MULTI_DATA))
+
+ def test_parent_without_data_invalid(self):
+ self.assertRaises(errors.MissingKeyError,
+ lambda: hybrid_validate(ins.PARENT_WITHOUT_DATA_INVALID))
+
+ def test_parent_with_data_and_invalid_data_invalid(self):
+ self.assertRaises(errors.InvalidTypeError,
+ lambda: hybrid_validate(ins.PARENT_WITH_DATA_AND_INVALID_DATA_INVALID))
+
+ def test_parent_with_invalid_data_invalid(self):
+ self.assertRaises(errors.InvalidTypeError,
+ lambda: hybrid_validate(ins.PARENT_WITH_INVALID_DATA_INVALID))
+
+ def test_parent_to_child(self):
+ self.assertIsNone(hybrid_validate(ins.PARENT_TO_CHILD))
+
+ def test_parent_to_children(self):
+ self.assertIsNone(hybrid_validate(ins.PARENT_TO_CHILDREN))
+
+ def test_parent_to_children_without_model(self):
+ self.assertIsNone(hybrid_validate(
+ ins.PARENT_TO_CHILDREN_WITHOUT_MODEL))
+
+ def test_parent_to_children_with_multi_data(self):
+ self.assertIsNone(hybrid_validate(
+ ins.PARENT_TO_CHILDREN_WITH_MULTI_DATA))
+
+ def test_parent_to_children_with_multi_data_without_model(self):
+ self.assertIsNone(hybrid_validate(
+ ins.PARENT_TO_CHILDREN_WITH_MULTI_DATA_WITHOUT_MODEL))
+
+
+class TestKey(unittest.TestCase):
+ def test_key_equal_key(self):
+ self.assertEqual(Key.model(), Key(name='model', type_=str))
+
+ def test_key_not_equal(self):
+ self.assertNotEqual(Key.model(), Key.data())
+
+ def test_key_equal_string(self):
+ self.assertEqual(Key.model(), 'model')
+
+ def test_key_not_equal_other_instance(self):
+ self.assertNotEqual(Key.model(), object())
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..8780be8
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,11 @@
+# content of: tox.ini , put in same dir as setup.py
+[tox]
+envlist = py36,37,38,39,310
+
+[testenv]
+# install pytest in the virtualenv where commands will be executed
+deps = pytest
+ -r{toxinidir}/requirements.txt
+commands =
+ # NOTE: you can run any command line tool here - not just tests
+ pytest