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 @@ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/sqlalchemyseed)](https://pypi.org/project/sqlalchemyseed) [![PyPI - License](https://img.shields.io/pypi/l/sqlalchemyseed)](https://github.com/jedymatt/sqlalchemyseed/blob/main/LICENSE) [![Python package](https://github.com/jedymatt/sqlalchemyseed/actions/workflows/python-package.yml/badge.svg)](https://github.com/jedymatt/sqlalchemyseed/actions/workflows/python-package.yml) -[![Build Status](https://app.travis-ci.com/jedymatt/sqlalchemyseed.svg?branch=main)](https://app.travis-ci.com/jedymatt/sqlalchemyseed) +[![Maintainability](https://api.codeclimate.com/v1/badges/2ca97c98929b614658ea/maintainability)](https://codeclimate.com/github/jedymatt/sqlalchemyseed/maintainability) +[![codecov](https://codecov.io/gh/jedymatt/sqlalchemyseed/branch/main/graph/badge.svg?token=W03MFZ2FAG)](https://codecov.io/gh/jedymatt/sqlalchemyseed) +[![Documentation Status](https://readthedocs.org/projects/sqlalchemyseed/badge/?version=latest)](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