├── .coveragerc ├── .github └── workflows │ └── tests_and_lint.yml ├── .gitignore ├── .pydocstyle ├── .pydocstyle_test ├── .pylintrc ├── AUTHORS.rst ├── CHANGELOG.rst ├── CODE_OF_CONDUCT.rst ├── CONTRIBUTING.rst ├── LICENSE.txt ├── Pipfile ├── Pipfile.lock ├── README.rst ├── codecov.yml ├── docker-compose.yml ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── about │ ├── changelog.rst │ ├── code_of_conduct.rst │ ├── contributing.rst │ ├── execution_model.rst │ └── faq.rst │ ├── advanced_features │ ├── additional_tools.rst │ ├── macro_system.rst │ └── schema_graph.rst │ ├── conf.py │ ├── index.rst │ ├── language_specification │ ├── definitions.rst │ ├── query_directives.rst │ └── schema_types.rst │ └── supported_databases │ ├── neo4j_and_redisgraph.rst │ ├── orientdb.rst │ └── sql.rst ├── graphql_compiler ├── __init__.py ├── api │ ├── __init__.py │ ├── orientdb.py │ ├── redisgraph.py │ └── sql │ │ ├── __init__.py │ │ ├── mssql.py │ │ ├── mysql.py │ │ └── postgres.py ├── ast_manipulation.py ├── backend.py ├── compiler │ ├── __init__.py │ ├── blocks.py │ ├── common.py │ ├── compiler_entities.py │ ├── compiler_frontend.py │ ├── context_helpers.py │ ├── cypher_helpers.py │ ├── cypher_query.py │ ├── directive_helpers.py │ ├── emit_cypher.py │ ├── emit_gremlin.py │ ├── emit_match.py │ ├── emit_sql.py │ ├── expressions.py │ ├── filters.py │ ├── helpers.py │ ├── ir_lowering_common │ │ ├── __init__.py │ │ ├── common.py │ │ └── location_renaming.py │ ├── ir_lowering_cypher │ │ ├── __init__.py │ │ └── ir_lowering.py │ ├── ir_lowering_gremlin │ │ ├── __init__.py │ │ └── ir_lowering.py │ ├── ir_lowering_match │ │ ├── __init__.py │ │ ├── between_lowering.py │ │ ├── ir_lowering.py │ │ ├── optional_traversal.py │ │ └── utils.py │ ├── ir_lowering_sql │ │ └── __init__.py │ ├── ir_self_consistency_checks.py │ ├── match_query.py │ ├── metadata.py │ ├── sqlalchemy_extensions.py │ ├── subclass.py │ ├── validation.py │ └── workarounds │ │ ├── __init__.py │ │ ├── orientdb_class_with_while.py │ │ ├── orientdb_eval_scheduling.py │ │ └── orientdb_query_execution.py ├── cost_estimation │ ├── __init__.py │ ├── analysis.py │ ├── cardinality_estimator.py │ ├── filter_selectivity_utils.py │ ├── helpers.py │ ├── int_value_conversion.py │ ├── interval.py │ └── statistics.py ├── debugging_utils.py ├── deserialization.py ├── exceptions.py ├── fast_introspection.py ├── global_utils.py ├── interpreter │ ├── __init__.py │ ├── debugging.py │ ├── immutable_stack.py │ └── typedefs.py ├── macros │ ├── __init__.py │ ├── macro_edge │ │ ├── __init__.py │ │ ├── ast_rewriting.py │ │ ├── ast_traversal.py │ │ ├── descriptor.py │ │ ├── directives.py │ │ ├── expansion.py │ │ ├── name_generation.py │ │ ├── reversal.py │ │ └── validation.py │ ├── macro_expansion.py │ └── validation.py ├── post_processing │ ├── __init__.py │ └── sql_post_processing.py ├── py.typed ├── query_formatting │ ├── __init__.py │ ├── common.py │ ├── cypher_formatting.py │ ├── graphql_formatting.py │ ├── gremlin_formatting.py │ ├── match_formatting.py │ ├── representations.py │ └── sql_formatting.py ├── query_pagination │ ├── __init__.py │ ├── pagination_planning.py │ ├── parameter_generator.py │ ├── query_parameterizer.py │ └── typedefs.py ├── query_planning │ ├── __init__.py │ ├── make_query_plan.py │ └── typedefs.py ├── schema │ ├── __init__.py │ ├── schema_info.py │ └── typedefs.py ├── schema_generation │ ├── __init__.py │ ├── exceptions.py │ ├── graphql_schema.py │ ├── orientdb │ │ ├── __init__.py │ │ ├── schema_graph_builder.py │ │ ├── schema_properties.py │ │ └── utils.py │ ├── schema_graph.py │ └── sqlalchemy │ │ ├── __init__.py │ │ ├── edge_descriptors.py │ │ ├── scalar_type_mapper.py │ │ ├── schema_graph_builder.py │ │ ├── sqlalchemy_reflector.py │ │ └── utils.py ├── schema_transformation │ ├── __init__.py │ ├── merge_schemas.py │ ├── rename_query.py │ ├── rename_schema.py │ ├── split_query.py │ └── utils.py ├── tests │ ├── __init__.py │ ├── complex_nested_optionals_output.sql │ ├── conftest.py │ ├── integration_tests │ │ ├── __init__.py │ │ ├── integration_backend_config.py │ │ ├── integration_test_helpers.py │ │ └── test_backends_integration.py │ ├── interpreter_tests │ │ ├── __init__.py │ │ └── test_immutable_stack.py │ ├── schema_generation_tests │ │ ├── __init__.py │ │ ├── test_orientdb_schema_generation.py │ │ └── test_sqlalchemy_schema_generation.py │ ├── schema_transformation_tests │ │ ├── __init__.py │ │ ├── example_schema.py │ │ ├── input_schema_strings.py │ │ ├── test_check_schema_valid.py │ │ ├── test_make_query_plan.py │ │ ├── test_merge_schemas.py │ │ ├── test_rename_query.py │ │ ├── test_rename_schema.py │ │ └── test_split_query.py │ ├── snapshot_tests │ │ ├── __init__.py │ │ ├── snapshots │ │ │ ├── __init__.py │ │ │ └── snap_test_orientdb_match_query.py │ │ ├── test_cost_estimation.py │ │ ├── test_cost_estimation_analysis.py │ │ ├── test_orientdb_match_query.py │ │ └── test_query_pagination.py │ ├── test_backend.py │ ├── test_compiler.py │ ├── test_data_tools │ │ ├── __init__.py │ │ ├── data_tool.py │ │ ├── neo4j_graph.py │ │ ├── orientdb_graph.py │ │ ├── redisgraph_graph.py │ │ ├── schema.py │ │ ├── schema.sql │ │ └── snapshot_data │ │ │ └── commands.sql │ ├── test_emit_output.py │ ├── test_end_to_end.py │ ├── test_explain_info.py │ ├── test_fast_introspection.py │ ├── test_global_utils.py │ ├── test_graphql_pretty_print.py │ ├── test_helpers.py │ ├── test_input_data.py │ ├── test_ir_generation.py │ ├── test_ir_generation_errors.py │ ├── test_ir_lowering.py │ ├── test_location.py │ ├── test_macro_expansion.py │ ├── test_macro_expansion_errors.py │ ├── test_macro_schema.py │ ├── test_macro_validation.py │ ├── test_post_processing.py │ ├── test_safe_match_and_gremlin.py │ ├── test_schema.py │ ├── test_schema_fingerprint.py │ ├── test_sqlalchemy_extensions.py │ ├── test_subclass.py │ ├── test_test_data.py │ └── test_testing_invariants.py ├── tool.py └── typedefs.py ├── mypy.ini ├── pyproject.toml ├── scripts ├── copyright_line_check.sh ├── fix_lint.sh ├── generate_test_sql │ ├── __init__.py │ ├── animals.py │ ├── events.py │ ├── species.py │ └── utils.py ├── install_ubuntu_ci_core_dependencies.sh ├── lint.sh └── make_new_release.sh ├── setup.cfg └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | dynamic_context = test_function 3 | omit = 4 | # No coverage for tests 5 | graphql_compiler/tests/* 6 | 7 | # No coverage for SQL command generation script 8 | scripts/generate_test_sql/* 9 | 10 | # No coverage for snapshots. 11 | **/snap_*.py 12 | 13 | [report] 14 | # Regexes for lines to exclude from consideration 15 | exclude_lines = 16 | # Have to re-enable the standard pragma 17 | pragma: no cover 18 | 19 | # Don't complain about missing debug-only code 20 | def __repr__ 21 | 22 | # Don't complain if tests don't hit defensive assertion code 23 | raise AssertionError 24 | raise NotImplementedError 25 | 26 | # Don't complain if non-runnable code isn't run: 27 | if __name__ == .__main__.: 28 | 29 | # Don't complain if ellipsis never gets executed 30 | ^[ ]*\.\.\.$ 31 | -------------------------------------------------------------------------------- /.github/workflows/tests_and_lint.yml: -------------------------------------------------------------------------------- 1 | name: Tests and lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "v*" 9 | pull_request: 10 | branches: 11 | - "*" 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | # Use a matrix for the python version here to make the rest of the lint job setup 18 | # as similar as possible to the test job below. 19 | python-version: [3.8] 20 | lint-flags: 21 | - "--run-only-fast-linters" 22 | - "--run-only-pylint" 23 | - "--run-only-mypy" 24 | - "--run-only-bandit" 25 | - "--run-only-sphinx-build" 26 | - "--run-only-typing-copilot-tighten" 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | - name: Install core dependencies 34 | run: | 35 | ./scripts/install_ubuntu_ci_core_dependencies.sh 36 | - name: Get pip cache dir 37 | id: pip-cache 38 | run: | 39 | echo "::set-output name=dir::$(pip cache dir)" 40 | - name: Cache the Python dependencies 41 | uses: actions/cache@v2 42 | with: 43 | path: ${{ steps.pip-cache.outputs.dir }} 44 | key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/Pipfile.lock') }} 45 | restore-keys: | 46 | ${{ runner.os }}-pip-${{ matrix.python-version }}- 47 | - name: Install Python dependencies 48 | run: | 49 | pipenv install --dev --deploy --system 50 | pip install -e . 51 | - name: Run lint checks 52 | if: matrix.lint-flags != '--run-only-typing-copilot-tighten' 53 | run: | 54 | pipenv run ./scripts/lint.sh ${{ matrix.lint-flags }} 55 | - name: Run typing_copilot to ensure tightest possible mypy config 56 | if: matrix.lint-flags == '--run-only-typing-copilot-tighten' 57 | run: | 58 | pipenv run typing_copilot tighten --error-if-can-tighten 59 | tests: 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | python-version: [3.6, 3.7, 3.8, 3.9] 64 | markers: ["not slow", "slow"] 65 | steps: 66 | - uses: actions/checkout@v2 67 | - name: Set up Python ${{ matrix.python-version }} 68 | uses: actions/setup-python@v2 69 | with: 70 | python-version: ${{ matrix.python-version }} 71 | - name: Install core dependencies 72 | run: | 73 | ./scripts/install_ubuntu_ci_core_dependencies.sh 74 | - name: Start database docker containers 75 | if: matrix.markers != 'not slow' # don't bring up db containers for non-slow tests 76 | run: | 77 | docker-compose up -d 78 | - name: Get pip cache dir 79 | id: pip-cache 80 | run: | 81 | echo "::set-output name=dir::$(pip cache dir)" 82 | - name: Cache the Python dependencies 83 | uses: actions/cache@v2 84 | with: 85 | path: ${{ steps.pip-cache.outputs.dir }} 86 | key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/Pipfile.lock') }} 87 | restore-keys: | 88 | ${{ runner.os }}-pip-${{ matrix.python-version }}- 89 | - name: Install Python dependencies 90 | run: | 91 | pipenv install --dev --deploy --system 92 | pip install -e . 93 | - name: Test with pytest 94 | run: | 95 | pytest --cov=graphql_compiler graphql_compiler/tests -m '${{ matrix.markers }}' 96 | codecov 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Py.test generated files 2 | **/__pycache__/ 3 | .pytest_cache/ 4 | 5 | # Apple file system litter 6 | .DS_Store 7 | 8 | # Python generated files 9 | *.py[cod] 10 | 11 | # Installer logs 12 | pip-log.txt 13 | 14 | # Unit test / coverage reports / type checker cache 15 | .cache/ 16 | .coverage 17 | nosetests.xml 18 | **/.mypy_cache/ 19 | 20 | # temporary files 21 | *.orig 22 | *~ 23 | .*~ 24 | *.swo 25 | *.swp 26 | 27 | # Virtual environment 28 | **/venv/ 29 | **/venv3/ 30 | 31 | # Python autogenerated egg files and folders 32 | *.egg-info 33 | *.egg-ignore 34 | 35 | # Any IDE-generated files (pycharm, Sublime, VSCode) 36 | .idea 37 | .idea/ 38 | .idea/* 39 | *.sublime-* 40 | .vscode/ 41 | 42 | # Sphinx docs 43 | tools/sphinx_docgen/docs_rst 44 | tools/sphinx_docgen/docs_html 45 | 46 | # Python package publishing directories 47 | build/ 48 | dist/ 49 | 50 | # vim and swap files 51 | *.vim 52 | *.swp 53 | *.swo 54 | 55 | # Jupyter notebook checkpoint files 56 | **/.ipynb_checkpoints/ 57 | 58 | # Emacs lockfiles 59 | .#* 60 | 61 | # Put anything in this directory to explicitly have it excluded. 62 | # Jupyter notebook files for local prototyping are a good use case. 63 | localdata/ 64 | -------------------------------------------------------------------------------- /.pydocstyle: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | ignore = D100,D101,D104,D202,D203,D213,D406,D407,D408,D409,D413 3 | match = (?!test_).*\.py 4 | -------------------------------------------------------------------------------- /.pydocstyle_test: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | ignore = D100,D101,D102,D104,D202,D203,D213,D406,D407,D408,D409,D413 3 | match = test_.*\.py 4 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | - Predrag Gruevski (github: obi1kenobi) 5 | - Amartya Shankha Biswas (github: amartyashankha) 6 | - Jeremy Meulemans (github: jmeulemans) 7 | - Pedro Mantica (github: pmantica1) 8 | - Bojan Serafimov (github: bojanserafimov) 9 | - Vladimir Maksimovski (github: realnerom) 10 | - Qi Qi (github: qqi0O0) 11 | - Leon Wu (github: lwprogramming) 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Contributor Covenant Code of Conduct 2 | ==================================== 3 | 4 | Our Pledge 5 | ---------- 6 | 7 | We as members, contributors, and leaders pledge to make participation in our 8 | community a harassment-free experience for everyone, regardless of age, body 9 | size, visible or invisible disability, ethnicity, sex characteristics, gender 10 | identity and expression, level of experience, education, socio-economic status, 11 | nationality, personal appearance, race, religion, or sexual identity 12 | and orientation. 13 | 14 | We pledge to act and interact in ways that contribute to an open, welcoming, 15 | diverse, inclusive, and healthy community. 16 | 17 | Our Standards 18 | ------------- 19 | 20 | Examples of behavior that contributes to a positive environment for our 21 | community include: 22 | 23 | - Demonstrating empathy and kindness toward other people 24 | - Being respectful of differing opinions, viewpoints, and experiences 25 | - Giving and gracefully accepting constructive feedback 26 | - Accepting responsibility and apologizing to those affected by our mistakes, 27 | and learning from the experience 28 | - Focusing on what is best not just for us as individuals, but for the 29 | overall community 30 | 31 | Examples of unacceptable behavior include: 32 | 33 | - The use of sexualized language or imagery, and sexual attention or 34 | advances of any kind 35 | - Trolling, insulting or derogatory comments, and personal or political attacks 36 | - Public or private harassment 37 | - Publishing others' private information, such as a physical or email 38 | address, without their explicit permission 39 | - Other conduct which could reasonably be considered inappropriate in a 40 | professional setting 41 | 42 | Enforcement Responsibilities 43 | ---------------------------- 44 | 45 | Community leaders are responsible for clarifying and enforcing our standards of 46 | acceptable behavior and will take appropriate and fair corrective action in 47 | response to any behavior that they deem inappropriate, threatening, offensive, 48 | or harmful. 49 | 50 | Community leaders have the right and responsibility to remove, edit, or reject 51 | comments, commits, code, wiki edits, issues, and other contributions that are 52 | not aligned to this Code of Conduct, and will communicate reasons for moderation 53 | decisions when appropriate. 54 | 55 | Scope 56 | ----- 57 | 58 | This Code of Conduct applies within all community spaces, and also applies when 59 | an individual is officially representing the community in public spaces. 60 | Examples of representing our community include using an official e-mail address, 61 | posting via an official social media account, or acting as an appointed 62 | representative at an online or offline event. 63 | 64 | Enforcement 65 | ----------- 66 | 67 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 68 | reported to the community leaders responsible for enforcement at 69 | graphql-compiler-maintainer@kensho.com. 70 | All complaints will be reviewed and investigated promptly and fairly. 71 | 72 | All community leaders are obligated to respect the privacy and security of the 73 | reporter of any incident. 74 | 75 | Enforcement Guidelines 76 | ---------------------- 77 | 78 | Community leaders will follow these Community Impact Guidelines in determining 79 | the consequences for any action they deem in violation of this Code of Conduct: 80 | 81 | 1. Correction 82 | ^^^^^^^^^^^^^ 83 | 84 | **Community Impact**: Use of inappropriate language or other behavior deemed 85 | unprofessional or unwelcome in the community. 86 | 87 | **Consequence**: A private, written warning from community leaders, providing 88 | clarity around the nature of the violation and an explanation of why the 89 | behavior was inappropriate. A public apology may be requested. 90 | 91 | 2. Warning 92 | ^^^^^^^^^^ 93 | 94 | **Community Impact**: A violation through a single incident or series 95 | of actions. 96 | 97 | **Consequence**: A warning with consequences for continued behavior. No 98 | interaction with the people involved, including unsolicited interaction with 99 | those enforcing the Code of Conduct, for a specified period of time. This 100 | includes avoiding interactions in community spaces as well as external channels 101 | like social media. Violating these terms may lead to a temporary or 102 | permanent ban. 103 | 104 | 3. Temporary Ban 105 | ^^^^^^^^^^^^^^^^ 106 | 107 | **Community Impact**: A serious violation of community standards, including 108 | sustained inappropriate behavior. 109 | 110 | **Consequence**: A temporary ban from any sort of interaction or public 111 | communication with the community for a specified period of time. No public or 112 | private interaction with the people involved, including unsolicited interaction 113 | with those enforcing the Code of Conduct, is allowed during this period. 114 | Violating these terms may lead to a permanent ban. 115 | 116 | 4. Permanent Ban 117 | ^^^^^^^^^^^^^^^^ 118 | 119 | **Community Impact**: Demonstrating a pattern of violation of community 120 | standards, including sustained inappropriate behavior, harassment of an 121 | individual, or aggression toward or disparagement of classes of individuals. 122 | 123 | **Consequence**: A permanent ban from any sort of public interaction within 124 | the community. 125 | 126 | Attribution 127 | ----------- 128 | 129 | This Code of Conduct is adapted from the `Contributor 130 | Covenant `__, version 2.0, available at 131 | `https://contributor-covenant.org/version/2/0 `__ 132 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | # Test requirements that are not otherwise necessary when using the package. 8 | mysqlclient = ">=1.4.6,<2" 9 | neo4j = ">=1.7.4,<2" 10 | psycopg2-binary = "==2.8.4" 11 | pyodbc = "==4.0.27" 12 | pyorient = "==1.5.5" 13 | redis = ">=3.2.1,<4" 14 | redisgraph = ">=1.7,<1.9" 15 | 16 | # Linters and other development tools 17 | bandit = ">=1.5.1,<1.7" # 1.7 has a blocking regression: https://github.com/PyCQA/bandit/issues/658 18 | black = "==20.8b1" # This is still marked as a beta release, pin it explicitly: https://github.com/pypa/pipenv/issues/1760 19 | codecov = ">=2.0.15,<3" 20 | flake8 = ">=3.6.0,<4" 21 | flake8-bugbear = ">=19.8.0" 22 | flake8-print = ">=3.1.0,<4" 23 | isort = ">=4.3.4,<5" 24 | mypy = ">=0.750,<0.800" # Breaking change in mypy 0.800: https://github.com/obi1kenobi/typing-copilot/issues/10 25 | parameterized = ">=0.6.1,<1" 26 | pydocstyle = ">=5.0.1,<6" 27 | pylint = ">=2.4.4,<2.5" 28 | pytest = ">=5.1.3,<6" 29 | pytest-cov = ">=2.6.1,<3" 30 | snapshottest = ">=0.5.1,<1" 31 | typing-copilot = {version = "==0.5.4", markers = "python_version >= '3.7'"} 32 | 33 | # TODO: add dependency on https://github.com/dropbox/sqlalchemy-stubs and corresponding mypy plugin 34 | # when we can make everything type-check correctly with it. 35 | 36 | # Documentation requirements. Keep in sync with docs/requirements.txt. 37 | # Read the Docs doesn't support pipfiles: https://github.com/readthedocs/readthedocs.org/issues/3181 38 | sphinx-rtd-theme = ">=0.4.3,<1" 39 | sphinx = ">=1.8,<2" 40 | 41 | [packages] # Make sure to keep in sync with setup.py requirements. 42 | ciso8601 = ">=2.1.3,<3" 43 | dataclasses-json = ">=0.5.2,<0.6" 44 | funcy = ">=1.7.3,<2" 45 | graphql-core = ">=3.1.2,<3.2" # minor versions sometimes contain breaking changes 46 | six = ">=1.10.0" 47 | sqlalchemy = ">=1.3.0,<1.4" # minor version update contains breaking changes 48 | 49 | # The below is necessary to make a few pylint passes work properly, since pylint expects to be able 50 | # to run "import graphql_compiler" in the environment in which it runs. 51 | graphql-compiler = {editable = true, path = "."} 52 | 53 | [requires] 54 | python_version = "3.8" 55 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | patch: 4 | default: 5 | target: auto 6 | threshold: 0.03% 7 | base: auto 8 | comment: 9 | after_n_builds: 14 # Prevent early, spurious Codecov reports before all tests finish: https://github.com/kensho-technologies/graphql-compiler/pull/806#issuecomment-730622647. 14 is calculated from the number of jobs to run, which is specified in the .github/workflows/tests_and_lint.yml file: 6 lint jobs (1 job per combination of python-version and lint-flags) and 8 test jobs (1 job per combination of python-version and markers). 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | orientdb: 4 | image: orientdb:2.2.30 5 | command: server.sh 6 | ports: 7 | - "127.0.0.1:2481:2480" 8 | - "127.0.0.1:2425:2424" 9 | environment: 10 | ORIENTDB_ROOT_PASSWORD: root 11 | postgres: 12 | image: postgres:10.5 13 | restart: always 14 | environment: 15 | POSTGRES_PASSWORD: root 16 | ports: 17 | - "127.0.0.1:5433:5432" 18 | mysql: 19 | image: mysql:8.0.11 20 | command: --default-authentication-plugin=mysql_native_password 21 | restart: always 22 | ports: 23 | - "127.0.0.1:3307:3306" 24 | environment: 25 | MYSQL_ROOT_PASSWORD: root 26 | mariadb: 27 | image: mariadb:10.3.11 28 | restart: always 29 | ports: 30 | - "127.0.0.1:3308:3306" 31 | environment: 32 | MYSQL_ROOT_PASSWORD: root 33 | mssql: 34 | image: mcr.microsoft.com/mssql/server:2017-latest 35 | restart: always 36 | ports: 37 | - "127.0.0.1:1434:1433" 38 | environment: 39 | ACCEPT_EULA: "yes" 40 | MSSQL_SA_PASSWORD: Root-secure1 # password requirements are more stringent for MSSQL image 41 | neo4j: 42 | image: neo4j:3.5.6 43 | restart: always 44 | ports: 45 | - "127.0.0.1:7475:7474" 46 | - "127.0.0.1:7688:7687" 47 | environment: 48 | NEO4J_AUTH: neo4j/root 49 | redisgraph: 50 | image: redislabs/redisgraph:1.2.2 51 | restart: always 52 | ports: 53 | - "127.0.0.1:6380:6379" 54 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-rtd-theme>=0.4.3,<1 2 | sphinx>=1.8,<2 3 | -------------------------------------------------------------------------------- /docs/source/about/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/source/about/code_of_conduct.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../../CODE_OF_CONDUCT.rst 2 | -------------------------------------------------------------------------------- /docs/source/about/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/source/about/execution_model.rst: -------------------------------------------------------------------------------- 1 | Execution model 2 | =============== 3 | 4 | Since the GraphQL compiler can target multiple different query languages, each with its own 5 | behaviors and limitations, the execution model must also be defined as a function of the 6 | compilation target language. While we strive to minimize the differences between compilation 7 | targets, some differences are unavoidable. 8 | 9 | The compiler abides by the following principles: 10 | 11 | - When the database is queried with a compiled query string, its response must always be in the 12 | form of a list of results. 13 | - The precise format of each such result is defined by each compilation target separately. 14 | 15 | - :code:`gremlin`, :code:`MATCH` and :code:`SQL` return data in a tabular format, where each 16 | result is a row of the table, and fields marked for output are columns. 17 | - However, future compilation targets may have a different format. For example, 18 | each result may appear in the nested tree format used by the standard 19 | GraphQL specification. 20 | - Each such result must satisfy all directives and types in its corresponding GraphQL query. 21 | - The returned list of results is **not** guaranteed to be complete! (This currently only applies 22 | to Gremlin - please follow this :ref:`link ` for more information on the issue). 23 | 24 | - In other words, there may have been additional result sets that satisfy all directives and 25 | types in the corresponding GraphQL query, but were not returned by the database. 26 | - However, compilation target implementations are encouraged to return complete results if at all 27 | practical. The :code:`MATCH` compilation target is guaranteed to produce complete results. 28 | -------------------------------------------------------------------------------- /docs/source/about/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | **Q: Do you really use GraphQL, or do you just use GraphQL-like 5 | syntax?** 6 | 7 | A: We really use GraphQL. Any query that the compiler will accept is 8 | entirely valid GraphQL, and we actually use the Python port of the 9 | GraphQL core library for parsing and type checking. However, since the 10 | database queries produced by compiling GraphQL are subject to the 11 | limitations of the database system they run on, our execution model is 12 | somewhat different compared to the one described in the standard GraphQL 13 | specification. 14 | 15 | .. TODO: Add a link to the execution model section. Once it is added. 16 | 17 | **Q: Does this project come with a GraphQL server implementation?** 18 | 19 | A: No -- there are many existing frameworks for running a web server. We 20 | simply built a tool that takes GraphQL query strings (and their 21 | parameters) and returns a query string you can use with your database. 22 | The compiler does not execute the query string against the database, nor 23 | does it deserialize the results. Therefore, it is agnostic to the choice 24 | of server framework and database client library used. 25 | 26 | **Q: Do you plan to support other databases / more GraphQL features in 27 | the future?** 28 | 29 | A: We'd love to, and we could really use your help! Please consider 30 | contributing to this project by opening issues, opening pull requests, 31 | or participating in discussions. 32 | 33 | **Q: I think I found a bug, what do I do?** 34 | 35 | A: Please check if an issue has already been created for the bug, and 36 | open a new one if not. Make sure to describe the bug in as much detail 37 | as possible, including any stack traces or error messages you may have 38 | seen, which database you're using, and what query you compiled. 39 | 40 | **Q: I think I found a security vulnerability, what do I do?** 41 | 42 | A: Please reach out to us at graphql-compiler-maintainer@kensho.com so 43 | we can triage the issue and take appropriate action. 44 | -------------------------------------------------------------------------------- /docs/source/advanced_features/additional_tools.rst: -------------------------------------------------------------------------------- 1 | Additional Tools 2 | ================ 3 | 4 | GraphQL Query Pretty-Printer 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | To pretty-print GraphQL queries, use the included pretty-printer: 8 | 9 | :: 10 | 11 | python -m graphql_compiler.tool output_file.graphql 12 | 13 | It's modeled after Python's :code:`json.tool`, reading from stdin and 14 | writing to stdout. 15 | -------------------------------------------------------------------------------- /docs/source/advanced_features/schema_graph.rst: -------------------------------------------------------------------------------- 1 | Schema Graph 2 | ============ 3 | 4 | When building a GraphQL schema from the database metadata, we first 5 | build a :code:`SchemaGraph` from the metadata and then, from the 6 | :code:`SchemaGraph`, build the GraphQL schema. The :code:`SchemaGraph` is also a 7 | representation of the underlying database schema, but it has three main 8 | advantages that make it a more powerful schema introspection tool: 9 | 10 | 1. It's able to store and expose a schema's index information. The interface for accessing index 11 | information is provisional though and might change in the near future. 12 | 2. Its classes are allowed to inherit from non-abstract classes. 13 | 3. It exposes many utility functions, such as :code:`get_subclass_set`, that make it easier to 14 | explore the schema. 15 | 16 | See below for a mock example of how to build and use the 17 | :code:`SchemaGraph`: 18 | 19 | .. code:: python 20 | 21 | from graphql_compiler.schema_generation.orientdb.schema_graph_builder import ( 22 | get_orientdb_schema_graph 23 | ) 24 | from graphql_compiler.schema_generation.orientdb.utils import ( 25 | ORIENTDB_INDEX_RECORDS_QUERY, ORIENTDB_SCHEMA_RECORDS_QUERY 26 | ) 27 | 28 | # Get schema metadata from hypothetical Animals database. 29 | client = your_function_that_returns_a_pyorient_client() 30 | schema_records = client.command(ORIENTDB_SCHEMA_RECORDS_QUERY) 31 | schema_data = [record.oRecordData for record in schema_records] 32 | 33 | # Get index data. 34 | index_records = client.command(ORIENTDB_INDEX_RECORDS_QUERY) 35 | index_query_data = [record.oRecordData for record in index_records] 36 | 37 | # Build SchemaGraph. 38 | schema_graph = get_orientdb_schema_graph(schema_data, index_query_data) 39 | 40 | # Get all the subclasses of a class. 41 | print(schema_graph.get_subclass_set('Animal')) 42 | # {'Animal', 'Dog'} 43 | 44 | # Get all the outgoing edge classes of a vertex class. 45 | print(schema_graph.get_vertex_schema_element_or_raise('Animal').out_connections) 46 | # {'Animal_Eats', 'Animal_FedAt', 'Animal_LivesIn'} 47 | 48 | # Get the vertex classes allowed as the destination vertex of an edge class. 49 | print(schema_graph.get_edge_schema_element_or_raise('Animal_Eats').out_connections) 50 | # {'Fruit', 'Food'} 51 | 52 | # Get the superclass of all classes allowed as the destination vertex of an edge class. 53 | print(schema_graph.get_edge_schema_element_or_raise('Animal_Eats').base_out_connection) 54 | # Food 55 | 56 | # Get the unique indexes defined on a class. 57 | print(schema_graph.get_unique_indexes_for_class('Animal')) 58 | # [IndexDefinition(name='uuid', 'base_classname'='Animal', fields={'uuid'}, unique=True, ordered=False, ignore_nulls=False)] 59 | 60 | We currently support :code:`SchemaGraph` auto-generation for both OrientDB and SQL database 61 | backends. In the future, we plan to add a mechanism where one can query a :code:`SchemaGraph` using 62 | GraphQL queries. 63 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "GraphQL Compiler" 21 | # Read the Docs uses the variable named copyright for the copyright message. 22 | # pylint: disable=redefined-builtin 23 | copyright = "2017-present Kensho Technologies, LLC." 24 | # pylint: enable=redefined-builtin 25 | author = "Predrag Gruevski, Pedro Mantica, Amartya Shankha Biswas, and Jeremy Meulemans" 26 | 27 | master_doc = "index" 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ["_templates"] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = [] 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # This theme is the most popular one and the one used by the 50 | # Read the Docs website https://docs.readthedocs.io/en/stable/index.html 51 | html_theme = "sphinx_rtd_theme" 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = ["_static"] 57 | 58 | # This specifies how many header levels below the title should we include in the table of contents 59 | # navigation bar. 60 | html_theme_options = { 61 | "navigation_depth": 3, 62 | } 63 | -------------------------------------------------------------------------------- /docs/source/language_specification/definitions.rst: -------------------------------------------------------------------------------- 1 | Definitions 2 | =========== 3 | 4 | .. TODO: Remove this page and integrate definitions more naturally into documentation. 5 | 6 | - **Vertex field**: A field corresponding to a vertex in the graph. In 7 | the below example, :code:`Animal` and :code:`out_Entity_Related` are vertex 8 | fields. The :code:`Animal` field is the field at which querying starts, 9 | and is therefore the **root vertex field**. In any scope, fields with 10 | the prefix :code:`out_` denote vertex fields connected by an outbound 11 | edge, whereas ones with the prefix :code:`in_` denote vertex fields 12 | connected by an inbound edge. 13 | 14 | .. code:: 15 | 16 | { 17 | Animal { 18 | name @output(out_name: "name") 19 | out_Entity_Related { 20 | ... on Species { 21 | description @output(out_name: "description") 22 | } 23 | } 24 | } 25 | } 26 | 27 | - **Property field**: A field corresponding to a property of a vertex 28 | in the graph. In the above example, the :code:`name` and :code:`description` 29 | fields are property fields. In any given scope, **property fields 30 | must appear before vertex fields**. 31 | - **Result set**: An assignment of vertices in the graph to scopes 32 | (locations) in the query. As the database processes the query, new 33 | result sets may be created (e.g. when traversing edges), and result 34 | sets may be discarded when they do not satisfy filters or type 35 | coercions. After all parts of the query are processed by the 36 | database, all remaining result sets are used to form the query 37 | result, by taking their values at all properties marked for output. 38 | - **Scope**: The part of a query between any pair of curly braces. The 39 | compiler infers the type of each scope. For example, in the above 40 | query, the scope beginning with :code:`Animal {` is of type :code:`Animal`, 41 | the one beginning with :code:`out_Entity_Related {` is of type 42 | :code:`Entity`, and the one beginning with :code:`... on Species {` is of 43 | type :code:`Species`. 44 | - **Type coercion**: An operation that produces a new scope of narrower 45 | type than the scope in which it exists. Any result sets that cannot 46 | satisfy the narrower type are filtered out and not returned. In the 47 | above query, :code:`... on Species` is a type coercion which takes its 48 | enclosing scope of type :code:`Entity`, and coerces it into a narrower 49 | scope of type :code:`Species`. This is possible since :code:`Entity` is an 50 | interface, and :code:`Species` is a type that implements the :code:`Entity` 51 | interface. 52 | 53 | -------------------------------------------------------------------------------- /docs/source/supported_databases/neo4j_and_redisgraph.rst: -------------------------------------------------------------------------------- 1 | Neo4j/Redisgraph 2 | ================ 3 | 4 | .. important 5 | 6 | Documentation on how to use the compiler to target cypher-based database backends is still a 7 | work in progress. 8 | 9 | Cypher query parameters 10 | ~~~~~~~~~~~~~~~~~~~~~~~ 11 | 12 | RedisGraph `doesn't support query 13 | parameters `__, 14 | so we perform manual parameter interpolation in the 15 | :code:`graphql_to_redisgraph_cypher` function. However, for Neo4j, we can 16 | use Neo4j's client to do parameter interpolation on its own so that we 17 | don't reinvent the wheel. 18 | 19 | The function :code:`insert_arguments_into_query` does so based on the query 20 | language, which isn't fine-grained enough here-- for Cypher backends, we 21 | only want to insert parameters if the backend is RedisGraph, but not if 22 | it's Neo4j. 23 | 24 | Instead, the correct approach for Neo4j Cypher is as follows, given a 25 | Neo4j Python client called :code:`neo4j_client`: 26 | 27 | .. code:: python 28 | 29 | common_schema_info = CommonSchemaInfo(schema, type_equivalence_hints) 30 | compilation_result = compile_graphql_to_cypher(common_schema_info, graphql_query) 31 | with neo4j_client.driver.session() as session: 32 | result = session.run(compilation_result.query, parameters) 33 | -------------------------------------------------------------------------------- /graphql_compiler/api/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/api/orientdb.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | # pylint: disable=unused-import 3 | from .. import graphql_to_gremlin, graphql_to_match # noqa 4 | from ..schema.schema_info import create_gremlin_schema_info, create_match_schema_info # noqa 5 | 6 | 7 | # pylint: enable=unused-import 8 | -------------------------------------------------------------------------------- /graphql_compiler/api/redisgraph.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | # pylint: disable=unused-import 3 | from .. import graphql_to_redisgraph_cypher # noqa 4 | from ..schema.schema_info import create_cypher_schema_info # noqa 5 | 6 | 7 | # pylint: enable=unused-import 8 | -------------------------------------------------------------------------------- /graphql_compiler/api/sql/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | # pylint: disable=unused-import 3 | # TODO: add more functions that help with SQL-related setup 4 | from ... import graphql_to_sql # noqa 5 | from ...schema_generation.sqlalchemy import get_sqlalchemy_schema_info # noqa 6 | 7 | 8 | # pylint: enable=unused-import 9 | -------------------------------------------------------------------------------- /graphql_compiler/api/sql/mssql.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | # pylint: disable=unused-import 3 | from ...schema.schema_info import create_mssql_schema_info # noqa 4 | 5 | 6 | # pylint: enable=unused-import 7 | -------------------------------------------------------------------------------- /graphql_compiler/api/sql/mysql.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | # pylint: disable=unused-import 3 | from ...schema.schema_info import create_mysql_schema_info # noqa 4 | 5 | 6 | # pylint: enable=unused-import 7 | -------------------------------------------------------------------------------- /graphql_compiler/api/sql/postgres.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | # pylint: disable=unused-import 3 | from ...schema.schema_info import create_postgresql_schema_info # noqa 4 | 5 | 6 | # pylint: enable=unused-import 7 | -------------------------------------------------------------------------------- /graphql_compiler/ast_manipulation.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from graphql.error import GraphQLSyntaxError 3 | from graphql.language.ast import ( 4 | DocumentNode, 5 | InlineFragmentNode, 6 | ListTypeNode, 7 | NonNullTypeNode, 8 | OperationDefinitionNode, 9 | OperationType, 10 | ) 11 | from graphql.language.parser import parse 12 | 13 | from .exceptions import GraphQLParsingError 14 | 15 | 16 | def get_ast_field_name(ast): 17 | """Return the field name for the given AST node.""" 18 | return ast.name.value 19 | 20 | 21 | def get_ast_field_name_or_none(ast): 22 | """Return the field name for the AST node, or None if the AST is an InlineFragment.""" 23 | if isinstance(ast, InlineFragmentNode): 24 | return None 25 | return get_ast_field_name(ast) 26 | 27 | 28 | def get_human_friendly_ast_field_name(ast): 29 | """Return a human-friendly name for the AST node, suitable for error messages.""" 30 | if isinstance(ast, InlineFragmentNode): 31 | return "type coercion to {}".format(ast.type_condition) 32 | elif isinstance(ast, OperationDefinitionNode): 33 | return "{} operation definition".format(ast.operation) 34 | 35 | return get_ast_field_name(ast) 36 | 37 | 38 | def safe_parse_graphql(graphql_string: str) -> DocumentNode: 39 | """Return an AST representation of the given GraphQL input, reraising GraphQL library errors.""" 40 | try: 41 | ast = parse(graphql_string) 42 | except GraphQLSyntaxError as e: 43 | raise GraphQLParsingError(e) from e 44 | 45 | return ast 46 | 47 | 48 | def get_only_query_definition(document_ast, desired_error_type): 49 | """Assert that the Document AST contains only a single definition for a query, and return it.""" 50 | if not isinstance(document_ast, DocumentNode) or not document_ast.definitions: 51 | raise AssertionError( 52 | 'Received an unexpected value for "document_ast": {}'.format(document_ast) 53 | ) 54 | 55 | if len(document_ast.definitions) != 1: 56 | raise desired_error_type( 57 | "Encountered multiple definitions within GraphQL input. This is not supported." 58 | "{}".format(document_ast.definitions) 59 | ) 60 | 61 | definition_ast = document_ast.definitions[0] 62 | if definition_ast.operation != OperationType.QUERY: 63 | raise desired_error_type( 64 | "Expected a GraphQL document with a single query definition, but instead found a " 65 | 'but instead found a "{}" operation. This is not supported.'.format( 66 | definition_ast.operation 67 | ) 68 | ) 69 | 70 | return definition_ast 71 | 72 | 73 | def get_only_selection_from_ast(ast, desired_error_type): 74 | """Return the selected sub-ast, ensuring that there is precisely one.""" 75 | selections = [] if ast.selection_set is None else ast.selection_set.selections 76 | 77 | if len(selections) != 1: 78 | ast_name = get_human_friendly_ast_field_name(ast) 79 | if selections: 80 | selection_names = [ 81 | get_human_friendly_ast_field_name(selection_ast) for selection_ast in selections 82 | ] 83 | raise desired_error_type( 84 | "Expected an AST with exactly one selection, but found " 85 | "{} selections at AST node named {}: {}".format( 86 | len(selection_names), selection_names, ast_name 87 | ) 88 | ) 89 | else: 90 | ast_name = get_human_friendly_ast_field_name(ast) 91 | raise desired_error_type( 92 | "Expected an AST with exactly one selection, but got " 93 | "one with no selections. Error near AST node named: {}".format(ast_name) 94 | ) 95 | 96 | return selections[0] 97 | 98 | 99 | def get_ast_with_non_null_stripped(ast): 100 | """Strip a NonNullType layer around the AST if there is one, return the underlying AST.""" 101 | if isinstance(ast, NonNullTypeNode): 102 | stripped_ast = ast.type 103 | if isinstance(stripped_ast, NonNullTypeNode): 104 | raise AssertionError( 105 | "NonNullType is unexpectedly found to wrap around another NonNullType in AST " 106 | "{}, which is not allowed.".format(ast) 107 | ) 108 | return stripped_ast 109 | else: 110 | return ast 111 | 112 | 113 | def get_ast_with_non_null_and_list_stripped(ast): 114 | """Strip any NonNullType or List layers around the AST, return the underlying AST.""" 115 | while isinstance(ast, (NonNullTypeNode, ListTypeNode)): 116 | ast = ast.type 117 | return ast 118 | -------------------------------------------------------------------------------- /graphql_compiler/backend.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from collections import namedtuple 3 | 4 | from .compiler import ( 5 | emit_cypher, 6 | emit_gremlin, 7 | emit_match, 8 | emit_sql, 9 | ir_lowering_cypher, 10 | ir_lowering_gremlin, 11 | ir_lowering_match, 12 | ir_lowering_sql, 13 | ) 14 | from .schema import schema_info 15 | 16 | 17 | # A backend is a compilation target (a language we can compile to) 18 | # 19 | # This class defines all the necessary and sufficient functionality a backend should implement 20 | # in order to fit into our generic testing framework. 21 | Backend = namedtuple( 22 | "Backend", 23 | ( 24 | # String, the internal name of this language. 25 | "language", 26 | # The subclass of SchemaInfo appropriate for this backend. 27 | "SchemaInfoClass", 28 | # Given a SchemaInfoClass and an IR that respects its schema, return a lowered IR with 29 | # the same semantics. 30 | "lower_func", 31 | # Given a SchemaInfoClass and a lowered IR that respects its schema, emit a query 32 | # in this language with the same semantics. 33 | "emit_func", 34 | ), 35 | ) 36 | 37 | 38 | gremlin_backend = Backend( 39 | language="Gremlin", 40 | SchemaInfoClass=schema_info.CommonSchemaInfo, 41 | lower_func=ir_lowering_gremlin.lower_ir, 42 | emit_func=emit_gremlin.emit_code_from_ir, 43 | ) 44 | 45 | match_backend = Backend( 46 | language="MATCH", 47 | SchemaInfoClass=schema_info.CommonSchemaInfo, 48 | lower_func=ir_lowering_match.lower_ir, 49 | emit_func=emit_match.emit_code_from_ir, 50 | ) 51 | 52 | cypher_backend = Backend( 53 | language="Cypher", 54 | SchemaInfoClass=schema_info.CommonSchemaInfo, 55 | lower_func=ir_lowering_cypher.lower_ir, 56 | emit_func=emit_cypher.emit_code_from_ir, 57 | ) 58 | 59 | sql_backend = Backend( 60 | language="SQL", 61 | SchemaInfoClass=schema_info.SQLAlchemySchemaInfo, 62 | lower_func=ir_lowering_sql.lower_ir, 63 | emit_func=emit_sql.emit_code_from_ir, 64 | ) 65 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | from .common import ( # noqa; noqa 3 | CYPHER_LANGUAGE, 4 | GREMLIN_LANGUAGE, 5 | MATCH_LANGUAGE, 6 | SQL_LANGUAGE, 7 | CompilationResult, 8 | compile_graphql_to_cypher, 9 | compile_graphql_to_gremlin, 10 | compile_graphql_to_match, 11 | compile_graphql_to_sql, 12 | ) 13 | from .compiler_frontend import OutputMetadata # noqa 14 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | from collections import namedtuple 3 | from typing import Union 4 | 5 | from .. import backend 6 | from ..backend import Backend 7 | from ..schema.schema_info import CommonSchemaInfo, SQLAlchemySchemaInfo 8 | from .compiler_frontend import graphql_to_ir 9 | 10 | 11 | # The CompilationResult will have the following types for its members: 12 | # - query: Union[String, sqlalchemy Query], the resulting compiled query, with placeholders for 13 | # parameters. 14 | # - language: string, specifying the language to which the query was compiled 15 | # - output_metadata: dict, output name -> OutputMetadata namedtuple object 16 | # - input_metadata: dict, name of input variables -> inferred GraphQL type, based on use 17 | CompilationResult = namedtuple( 18 | "CompilationResult", ("query", "language", "output_metadata", "input_metadata") 19 | ) 20 | 21 | MATCH_LANGUAGE = backend.match_backend.language 22 | GREMLIN_LANGUAGE = backend.gremlin_backend.language 23 | SQL_LANGUAGE = backend.sql_backend.language 24 | CYPHER_LANGUAGE = backend.cypher_backend.language 25 | 26 | 27 | def compile_graphql_to_match( 28 | common_schema_info: CommonSchemaInfo, graphql_query: str 29 | ) -> CompilationResult: 30 | """Compile the GraphQL input using the schema into a MATCH query and associated metadata. 31 | 32 | Args: 33 | common_schema_info: GraphQL schema object describing the schema of the graph to be queried 34 | graphql_query: str, GraphQL query to compile to MATCH 35 | 36 | Returns: 37 | CompilationResult object 38 | """ 39 | return _compile_graphql_generic(backend.match_backend, common_schema_info, graphql_query) 40 | 41 | 42 | def compile_graphql_to_gremlin( 43 | common_schema_info: CommonSchemaInfo, graphql_query: str 44 | ) -> CompilationResult: 45 | """Compile the GraphQL input using the schema into a Gremlin query and associated metadata. 46 | 47 | Args: 48 | common_schema_info: GraphQL schema object describing the schema of the graph to be queried 49 | graphql_query: the GraphQL query to compile to Gremlin, as a string 50 | 51 | Returns: 52 | CompilationResult object 53 | """ 54 | return _compile_graphql_generic(backend.gremlin_backend, common_schema_info, graphql_query) 55 | 56 | 57 | def compile_graphql_to_sql( 58 | sql_schema_info: SQLAlchemySchemaInfo, graphql_query: str 59 | ) -> CompilationResult: 60 | """Compile the GraphQL input using the schema into a SQL query and associated metadata. 61 | 62 | Args: 63 | sql_schema_info: SQLAlchemySchemaInfo used to compile the query. 64 | graphql_query: str, GraphQL query to compile to SQL 65 | 66 | Returns: 67 | CompilationResult object 68 | """ 69 | return _compile_graphql_generic(backend.sql_backend, sql_schema_info, graphql_query) 70 | 71 | 72 | def compile_graphql_to_cypher( 73 | common_schema_info: CommonSchemaInfo, graphql_query: str 74 | ) -> CompilationResult: 75 | """Compile the GraphQL input using the schema into a Cypher query and associated metadata. 76 | 77 | Args: 78 | common_schema_info: GraphQL schema object describing the schema of the graph to be queried 79 | graphql_query: the GraphQL query to compile to Cypher, as a string 80 | 81 | Returns: 82 | CompilationResult object 83 | """ 84 | return _compile_graphql_generic(backend.cypher_backend, common_schema_info, graphql_query) 85 | 86 | 87 | def _compile_graphql_generic( 88 | target_backend: Backend, 89 | schema_info: Union[CommonSchemaInfo, SQLAlchemySchemaInfo], 90 | graphql_string: str, 91 | ) -> CompilationResult: 92 | """Compile the GraphQL input, lowering and emitting the query using the given functions. 93 | 94 | Args: 95 | target_backend: Backend used to compile the query 96 | schema_info: target_backend.schemaInfoClass containing all necessary schema information. 97 | graphql_string: str, GraphQL query to compile to the target language 98 | 99 | Returns: 100 | CompilationResult object 101 | """ 102 | ir_and_metadata = graphql_to_ir( 103 | schema_info.schema, 104 | graphql_string, 105 | type_equivalence_hints=schema_info.type_equivalence_hints, 106 | ) 107 | 108 | lowered_ir_blocks = target_backend.lower_func(schema_info, ir_and_metadata) 109 | query = target_backend.emit_func(schema_info, lowered_ir_blocks) 110 | return CompilationResult( 111 | query=query, 112 | language=target_backend.language, 113 | output_metadata=ir_and_metadata.output_metadata, 114 | input_metadata=ir_and_metadata.input_metadata, 115 | ) 116 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/context_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | """Helper functions for dealing with the frontend "context" object.""" 3 | 4 | from ..exceptions import GraphQLCompilationError 5 | from ..schema import COUNT_META_FIELD_NAME 6 | 7 | 8 | CONTEXT_FOLD_INNERMOST_SCOPE = "fold_innermost_scope" 9 | CONTEXT_FOLD_HAS_COUNT_FILTER = "fold_has_count_filter" 10 | CONTEXT_FOLD = "fold" 11 | CONTEXT_OPTIONAL = "optional" 12 | CONTEXT_OUTPUT_SOURCE = "output_source" 13 | 14 | 15 | def is_in_fold_innermost_scope(context): 16 | """Return True if the current context is within a scope marked @fold.""" 17 | return CONTEXT_FOLD_INNERMOST_SCOPE in context 18 | 19 | 20 | def unmark_fold_innermost_scope(context): 21 | """Remove the context mark signaling an innermost fold scope.""" 22 | del context[CONTEXT_FOLD_INNERMOST_SCOPE] 23 | 24 | 25 | def set_fold_innermost_scope(context): 26 | """Set a mark indicating the innermost scope of a fold scope.""" 27 | context[CONTEXT_FOLD_INNERMOST_SCOPE] = True 28 | 29 | 30 | def is_in_fold_scope(context): 31 | """Return True if the current context is within a scope marked @fold.""" 32 | return CONTEXT_FOLD in context 33 | 34 | 35 | def get_context_fold_info(context): 36 | """Return the fold info stored in the context.""" 37 | return context[CONTEXT_FOLD] 38 | 39 | 40 | def unmark_context_fold_scope(context): 41 | """Return the context mark signaling the presence of a scope marked @fold.""" 42 | del context[CONTEXT_FOLD] 43 | 44 | 45 | def set_fold_scope_data(context, data): 46 | """Set fold scope data in the context.""" 47 | context[CONTEXT_FOLD] = data 48 | 49 | 50 | def has_fold_count_filter(context): 51 | """Return True if the current context contains a filter on the _x_count field.""" 52 | return CONTEXT_FOLD_HAS_COUNT_FILTER in context 53 | 54 | 55 | def unmark_fold_count_filter(context): 56 | """Remove the context mark signaling the existence of a fold count filter.""" 57 | del context[CONTEXT_FOLD_HAS_COUNT_FILTER] 58 | 59 | 60 | def set_fold_count_filter(context): 61 | """Set a mark indicating the presence of a filter on a fold _x_count field.""" 62 | context[CONTEXT_FOLD_HAS_COUNT_FILTER] = True 63 | 64 | 65 | def is_in_optional_scope(context): 66 | """Return True if the current context is within a scope marked @optional.""" 67 | return CONTEXT_OPTIONAL in context 68 | 69 | 70 | def get_optional_scope_or_none(context): 71 | """Return the optional scope data recorded in the context, or None if no such data.""" 72 | return context.get(CONTEXT_OPTIONAL, None) 73 | 74 | 75 | def set_optional_scope_data(context, data): 76 | """Set optional scope data in the context.""" 77 | context[CONTEXT_OPTIONAL] = data 78 | 79 | 80 | def unmark_optional_scope(context): 81 | """Remove the context mark signaling the existence of an optional scope.""" 82 | del context[CONTEXT_OPTIONAL] 83 | 84 | 85 | def set_output_source_data(context, data): 86 | """Set output source data in the context.""" 87 | context[CONTEXT_OUTPUT_SOURCE] = data 88 | 89 | 90 | def has_encountered_output_source(context): 91 | """Return True if the current context has already encountered an @output_source directive.""" 92 | return CONTEXT_OUTPUT_SOURCE in context 93 | 94 | 95 | def validate_context_for_visiting_vertex_field(parent_location, vertex_field_name, context): 96 | """Ensure that the current context allows for visiting a vertex field.""" 97 | if is_in_fold_innermost_scope(context): 98 | raise GraphQLCompilationError( 99 | "Traversing inside a @fold block after filtering on {} or outputting fields " 100 | "is not supported! Parent location: {}, vertex field name: {}".format( 101 | COUNT_META_FIELD_NAME, parent_location, vertex_field_name 102 | ) 103 | ) 104 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/cypher_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | """Helper functions for Cypher, such as generating unique vertex names within fold scopes.""" 3 | from .helpers import FoldScopeLocation, Location 4 | 5 | 6 | def get_fold_scope_location_full_path_name(fold_scope_location): 7 | """Return a unique name with the full traversal path to this FoldScopeLocation.""" 8 | # HACK(Leon): Get a unique name for each vertex in a fold traversal in Cypher. 9 | # For FoldScopeLocation objects, get_location_name() only uses the first edge in the traversal, 10 | # which doesn't work for Cypher because we need to explicitly label every intermediate vertex 11 | # along that path. 12 | # For other query languages like MATCH and Gremlin, the fold directive doesn't require 13 | # us to name all the intermediate vertices on the path, which is why this is Cypher-specific. 14 | if not (isinstance(fold_scope_location, FoldScopeLocation)): 15 | raise TypeError( 16 | "Expected cypher_step.as_block.location to be of type " 17 | "FoldScopeLocation. Instead, got object {} of type {}.".format( 18 | fold_scope_location, type(fold_scope_location) 19 | ) 20 | ) 21 | base_location = fold_scope_location.base_location 22 | base_query_path = base_location.query_path # the path traversed so far 23 | fold_path = fold_scope_location.fold_path # the path specified at or within the folded scope. 24 | full_path = base_query_path + tuple("_".join(edge_name) for edge_name in fold_path) 25 | location = Location( 26 | full_path, field=base_location.field, visit_counter=base_location.visit_counter 27 | ) 28 | if base_location.field is not None: 29 | raise ValueError( 30 | "Expected base_location's field to be None since this method is used to " 31 | "traverse vertices for a fold scope and at no point do we navigate to a " 32 | "field. However, field was {}".format(base_location.field) 33 | ) 34 | step_location_name, _ = location.get_location_name() 35 | return step_location_name 36 | 37 | 38 | def get_unique_vertex_name_from_location(location): 39 | """Return a unique name for this location, whether or not it's in a fold scope.""" 40 | if isinstance(location, FoldScopeLocation): 41 | return get_fold_scope_location_full_path_name(location) 42 | elif isinstance(location, Location): 43 | location_name, _ = location.get_location_name() 44 | return location_name 45 | raise TypeError( 46 | "Expected location to be of type Location or FoldScopeLocation. Instead got " 47 | "type {} for location {}".format(type(location), location) 48 | ) 49 | 50 | 51 | def get_collected_vertex_list_name(full_path_name): 52 | """Return the name of the list generated by folding vertices with name full_path_name.""" 53 | return "collected_" + full_path_name 54 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/emit_gremlin.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | """Convert lowered IR basic blocks to Gremlin query strings.""" 3 | 4 | 5 | ############## 6 | # Public API # 7 | ############## 8 | 9 | 10 | def emit_code_from_ir(schema_info, ir_blocks): 11 | """Return a MATCH query string from a list of IR blocks.""" 12 | gremlin_steps = (block.to_gremlin() for block in ir_blocks) 13 | 14 | # OutputSource blocks translate to empty steps. 15 | # Discard such empty steps so we don't end up with an incorrect concatenation. 16 | non_empty_steps = (step for step in gremlin_steps if step) 17 | 18 | return ".".join(non_empty_steps) 19 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/ir_lowering_common/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/ir_lowering_common/location_renaming.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | """Utilities for rewriting IR to replace one set of locations with another.""" 3 | from typing import Callable, Dict, TypeVar 4 | 5 | import six 6 | 7 | from ...compiler.expressions import ExpressionT 8 | from ..helpers import FoldScopeLocation, Location 9 | from ..metadata import QueryMetadataTable 10 | 11 | 12 | # This type exists in order to reduce the scope of what is allowed to be translated 13 | # since LocationT was not sufficiently specific. 14 | TranslatedLocationT = TypeVar("TranslatedLocationT", Location, FoldScopeLocation) 15 | 16 | 17 | def make_revisit_location_translations( 18 | query_metadata_table: QueryMetadataTable, 19 | ) -> Dict[Location, Location]: 20 | """Return a dict mapping location revisits to the location being revisited, for rewriting.""" 21 | location_translations = dict() 22 | 23 | for location, _ in query_metadata_table.registered_locations: 24 | if isinstance(location, Location): 25 | location_being_revisited = query_metadata_table.get_revisit_origin(location) 26 | if location_being_revisited != location: 27 | location_translations[location] = location_being_revisited 28 | 29 | return location_translations 30 | 31 | 32 | def translate_potential_location( 33 | location_translations: Dict[Location, Location], 34 | potential_location: TranslatedLocationT, 35 | ) -> TranslatedLocationT: 36 | """If the input is a BaseLocation object, translate it, otherwise return it as-is.""" 37 | if isinstance(potential_location, Location): 38 | old_location_at_vertex = potential_location.at_vertex() 39 | field = potential_location.field 40 | 41 | new_location = location_translations.get(old_location_at_vertex, None) 42 | if new_location is None: 43 | # No translation needed. 44 | return potential_location 45 | else: 46 | # If necessary, add the field component to the new location before returning it. 47 | if field is None: 48 | return new_location 49 | else: 50 | return new_location.navigate_to_field(field) 51 | elif isinstance(potential_location, FoldScopeLocation): 52 | old_base_location = potential_location.base_location 53 | new_base_location = location_translations.get(old_base_location, old_base_location) 54 | fold_path = potential_location.fold_path 55 | fold_field = potential_location.field 56 | return FoldScopeLocation(new_base_location, fold_path, field=fold_field) 57 | else: 58 | return potential_location 59 | 60 | 61 | def make_location_rewriter_visitor_fn( 62 | location_translations: Dict[Location, Location] 63 | ) -> Callable[[ExpressionT], ExpressionT]: 64 | """Return a visitor function that is able to replace locations with equivalent locations.""" 65 | 66 | def visitor_fn(expression: ExpressionT) -> ExpressionT: 67 | """Expression visitor function used to rewrite expressions with updated Location data.""" 68 | # All CompilerEntity objects store their exact constructor input args/kwargs. 69 | # To minimize the chances that we forget to update a location somewhere in an expression, 70 | # we rewrite all locations that we find as arguments to expression constructors. 71 | # pylint: disable=protected-access 72 | new_args = [ 73 | translate_potential_location(location_translations, arg) 74 | for arg in expression._print_args 75 | ] 76 | new_kwargs = { 77 | kwarg_name: translate_potential_location(location_translations, kwarg_value) 78 | for kwarg_name, kwarg_value in six.iteritems(expression._print_kwargs) 79 | } 80 | # pylint: enable=protected-access 81 | 82 | expression_cls = type(expression) 83 | return expression_cls(*new_args, **new_kwargs) 84 | 85 | return visitor_fn 86 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/ir_lowering_cypher/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from ..cypher_query import convert_to_cypher_query 3 | from ..ir_lowering_common.common import ( 4 | lower_context_field_existence, 5 | merge_consecutive_filter_clauses, 6 | optimize_boolean_expression_comparisons, 7 | ) 8 | from ..ir_self_consistency_checks import self_consistency_check_ir_blocks_from_frontend 9 | from .ir_lowering import ( 10 | insert_explicit_type_bounds, 11 | move_filters_in_optional_locations_to_global_operations, 12 | remove_mark_location_after_optional_backtrack, 13 | renumber_locations_to_one, 14 | replace_local_fields_with_context_fields, 15 | ) 16 | 17 | 18 | ############## 19 | # Public API # 20 | ############## 21 | 22 | 23 | def lower_ir(schema_info, ir): 24 | """Lower the IR into an IR form that can be represented in Cypher queries. 25 | 26 | Args: 27 | schema_info: CommonSchemaInfo containing all relevant schema information 28 | ir: IrAndMetadata representing the query to lower into Cypher-compatible form 29 | 30 | Returns: 31 | CypherQuery object 32 | """ 33 | self_consistency_check_ir_blocks_from_frontend(ir.ir_blocks, ir.query_metadata_table) 34 | 35 | ir_blocks = insert_explicit_type_bounds( 36 | ir.ir_blocks, 37 | ir.query_metadata_table, 38 | type_equivalence_hints=schema_info.type_equivalence_hints, 39 | ) 40 | 41 | ir_blocks = remove_mark_location_after_optional_backtrack(ir_blocks, ir.query_metadata_table) 42 | ir_blocks = lower_context_field_existence(ir_blocks, ir.query_metadata_table) 43 | ir_blocks = replace_local_fields_with_context_fields(ir_blocks) 44 | ir_blocks = optimize_boolean_expression_comparisons(ir_blocks) 45 | ir_blocks = merge_consecutive_filter_clauses(ir_blocks) 46 | ir_blocks = renumber_locations_to_one(ir_blocks) 47 | 48 | cypher_query = convert_to_cypher_query( 49 | ir_blocks, 50 | ir.query_metadata_table, 51 | type_equivalence_hints=schema_info.type_equivalence_hints, 52 | ) 53 | 54 | cypher_query = move_filters_in_optional_locations_to_global_operations( 55 | cypher_query, ir.query_metadata_table 56 | ) 57 | 58 | return cypher_query 59 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/ir_lowering_gremlin/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | from ..ir_lowering_common.common import ( 3 | lower_context_field_existence, 4 | merge_consecutive_filter_clauses, 5 | optimize_boolean_expression_comparisons, 6 | ) 7 | from ..ir_self_consistency_checks import self_consistency_check_ir_blocks_from_frontend 8 | from .ir_lowering import ( 9 | lower_coerce_type_block_type_data, 10 | lower_coerce_type_blocks, 11 | lower_folded_outputs_and_context_fields, 12 | rewrite_filters_in_optional_blocks, 13 | ) 14 | 15 | 16 | ############## 17 | # Public API # 18 | ############## 19 | 20 | 21 | def lower_ir(schema_info, ir): 22 | """Lower the IR into an IR form that can be represented in Gremlin queries. 23 | 24 | Args: 25 | schema_info: CommonSchemaInfo containing all relevant schema information 26 | ir: IrAndMetadata representing the query to lower into Gremlin-compatible form 27 | 28 | Returns: 29 | list of IR blocks suitable for outputting as Gremlin 30 | """ 31 | self_consistency_check_ir_blocks_from_frontend(ir.ir_blocks, ir.query_metadata_table) 32 | 33 | ir_blocks = lower_context_field_existence(ir.ir_blocks, ir.query_metadata_table) 34 | ir_blocks = optimize_boolean_expression_comparisons(ir_blocks) 35 | 36 | if schema_info.type_equivalence_hints: 37 | ir_blocks = lower_coerce_type_block_type_data(ir_blocks, schema_info.type_equivalence_hints) 38 | 39 | ir_blocks = lower_coerce_type_blocks(ir_blocks) 40 | ir_blocks = rewrite_filters_in_optional_blocks(ir_blocks) 41 | ir_blocks = merge_consecutive_filter_clauses(ir_blocks) 42 | ir_blocks = lower_folded_outputs_and_context_fields(ir_blocks) 43 | 44 | return ir_blocks 45 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/sqlalchemy_extensions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from copy import copy 3 | from typing import Any, Dict, List, Union 4 | 5 | from graphql.type.definition import GraphQLList, GraphQLType 6 | import sqlalchemy 7 | from sqlalchemy.dialects.mssql.pyodbc import MSDialect_pyodbc 8 | from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2 9 | from sqlalchemy.sql.elements import TextClause 10 | from sqlalchemy.sql.selectable import Select 11 | 12 | 13 | def contains_operator(collection, element): 14 | """Return a sqlalchemy BinaryExpression representing this operator. 15 | 16 | Args: 17 | collection: sqlalchemy BindParameter, a collection runtime parameter 18 | element: sqlalchemy Column that needs to be in the specified collection 19 | 20 | Returns: 21 | sqlalchemy BinaryExpression 22 | """ 23 | if not isinstance(collection, sqlalchemy.sql.elements.BindParameter): 24 | raise AssertionError( 25 | "Argument collection was expected to be a {}, but was a {}.".format( 26 | sqlalchemy.sql.elements.BindParameter, type(collection) 27 | ) 28 | ) 29 | if not isinstance(element, sqlalchemy.sql.schema.Column): 30 | raise AssertionError( 31 | "Argument element was expected to be a {}, but was a {}.".format( 32 | sqlalchemy.sql.schema.Column, type(element) 33 | ) 34 | ) 35 | 36 | return element.in_(collection) 37 | 38 | 39 | def not_contains_operator(collection, element): 40 | """Return a sqlalchemy BinaryExpression representing this operator. 41 | 42 | Args: 43 | collection: sqlalchemy BindParameter, a collection runtime parameter 44 | element: sqlalchemy Column that needs to be in the specified collection 45 | 46 | Returns: 47 | sqlalchemy BinaryExpression 48 | """ 49 | if not isinstance(collection, sqlalchemy.sql.elements.BindParameter): 50 | raise AssertionError( 51 | "Argument collection was expected to be a {}, but was a {}.".format( 52 | sqlalchemy.sql.elements.BindParameter, type(collection) 53 | ) 54 | ) 55 | if not isinstance(element, sqlalchemy.sql.schema.Column): 56 | raise AssertionError( 57 | "Argument element was expected to be a {}, but was a {}.".format( 58 | sqlalchemy.sql.schema.Column, type(element) 59 | ) 60 | ) 61 | 62 | return element.notin_(collection) 63 | 64 | 65 | def print_sqlalchemy_query_string( 66 | query: Select, dialect: Union[PGDialect_psycopg2, MSDialect_pyodbc] 67 | ) -> str: 68 | """Return a string form of the parameterized query. 69 | 70 | Args: 71 | query: sqlalchemy.sql.selectable.Select 72 | dialect: currently only postgres and mssql are supported because we have no 73 | tests for the others, but chances are that this function would still work. 74 | 75 | Returns: 76 | string that can be ran using sqlalchemy.sql.text(result) 77 | """ 78 | 79 | # The parameter style is one of the following: 80 | # { 81 | # "pyformat": "%%(%(name)s)s", 82 | # "qmark": "?", 83 | # "format": "%%s", 84 | # "numeric": ":[_POSITION]", 85 | # "named": ":%(name)s", 86 | # } 87 | # 88 | # We use the named parameter style since that's the only one 89 | # that the regex parser in the sqlalchemy TextClause object 90 | # understands. 91 | printing_dialect = copy(dialect) 92 | printing_dialect.paramstyle = "named" 93 | 94 | # Silencing mypy here since it can't infer the type of dialect.statement_compiler 95 | class BindparamCompiler(printing_dialect.statement_compiler): # type: ignore # noqa 96 | def visit_bindparam(self, bindparam, **kwargs): 97 | # A bound parameter with name param is represented as ":param". However, 98 | # if the parameter is expanding (list-valued) it is represented as 99 | # "([EXPANDING_param])" by default. This is an internal sqlalchemy 100 | # representation that is not understood by databases, so we explicitly 101 | # make sure to print it as ":param". 102 | bindparam.expanding = False 103 | return super(BindparamCompiler, self).visit_bindparam(bindparam, **kwargs) 104 | 105 | return str(BindparamCompiler(printing_dialect, query).process(query)) 106 | 107 | 108 | def bind_parameters_to_query_string( 109 | query: str, input_metadata: Dict[str, GraphQLType], parameters: Dict[str, Any] 110 | ) -> TextClause: 111 | """Assign values to query parameters.""" 112 | bound_parameters = [] 113 | for parameter_name, parameter_value in parameters.items(): 114 | parameter_type = input_metadata[parameter_name] 115 | is_list = isinstance(parameter_type, GraphQLList) 116 | bound_parameters.append( 117 | sqlalchemy.bindparam(parameter_name, value=parameter_value, expanding=is_list) 118 | ) 119 | 120 | return sqlalchemy.text(query).bindparams(*bound_parameters) 121 | 122 | 123 | def materialize_result_proxy(result: sqlalchemy.engine.result.ResultProxy) -> List[Dict[str, Any]]: 124 | """Drain the results from a result proxy into a list of dicts representation.""" 125 | return [dict(row) for row in result] 126 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/subclass.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from typing import Dict, Optional, Set 3 | 4 | from graphql import GraphQLInterfaceType, GraphQLObjectType, GraphQLSchema, GraphQLUnionType 5 | import six 6 | 7 | from ..schema.typedefs import TypeEquivalenceHintsType 8 | 9 | 10 | def compute_subclass_sets( 11 | schema: GraphQLSchema, type_equivalence_hints: Optional[TypeEquivalenceHintsType] = None 12 | ) -> Dict[str, Set[str]]: 13 | """Return a dict mapping class names to the set of its subclass names. 14 | 15 | A class here means an object type or interface. 16 | 17 | B is a subclass of A if any of the following conditions hold: 18 | - B is the same class as A 19 | - A is an interface and B implements it 20 | - A is equivalent to a union type (see type_equivalence_hints) and B is a member of it 21 | - B is a subclass of C and C is a subclass of A 22 | 23 | Args: 24 | schema: GraphQL schema object, obtained from the graphql library 25 | type_equivalence_hints: optional dict of GraphQL type to equivalent GraphQL union 26 | 27 | Returns: 28 | dict mapping class names to the set of its subclass names. 29 | """ 30 | if type_equivalence_hints is None: 31 | type_equivalence_hints = {} 32 | 33 | # A class is a subclass of itself. 34 | subclass_set = { 35 | classname: {classname} 36 | for classname, graphql_type in six.iteritems(schema.type_map) 37 | if isinstance(graphql_type, (GraphQLInterfaceType, GraphQLObjectType)) 38 | } 39 | 40 | # A class is a subclass of interfaces it implements. 41 | for classname, graphql_type in six.iteritems(schema.type_map): 42 | if isinstance(graphql_type, GraphQLObjectType): 43 | for interface in graphql_type.interfaces: 44 | subclass_set[interface.name].add(classname) 45 | 46 | # The base of the union is a superclass of other members. 47 | for graphql_type, equivalent_type in six.iteritems(type_equivalence_hints): 48 | if isinstance(equivalent_type, GraphQLUnionType): 49 | for subclass in equivalent_type.types: 50 | subclass_set[graphql_type.name].add(subclass.name) 51 | else: 52 | raise AssertionError("Unexpected type {}".format(type(equivalent_type))) 53 | 54 | # Note that the inheritance structure in the GraphQL schema is already transitive. Union types 55 | # encompass all of the object type subclasses of their equivalent object type and cannot 56 | # encompass other union types. Interface types are implemented by all their object type 57 | # subclasses and cannot be implemented by other interface types. 58 | return subclass_set 59 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/validation.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from typing import List 3 | 4 | from graphql import DocumentNode, GraphQLSchema 5 | from graphql.language import DirectiveLocation 6 | from graphql.validation import validate 7 | import six 8 | 9 | from ..schema import DIRECTIVES 10 | 11 | 12 | def validate_schema_and_query_ast(schema: GraphQLSchema, query_ast: DocumentNode) -> List[str]: 13 | """Validate the supplied GraphQL schema and query_ast. 14 | 15 | This method wraps around graphql-core's validation to enforce a stricter requirement of the 16 | schema -- all directives supported by the compiler must be declared by the schema, regardless of 17 | whether each directive is used in the query or not. 18 | 19 | Args: 20 | schema: GraphQL schema object, created using the GraphQL library 21 | query_ast: abstract syntax tree representation of a GraphQL query 22 | 23 | Returns: 24 | list containing schema and/or query validation errors 25 | """ 26 | core_graphql_errors = [str(error) for error in validate(schema, query_ast)] 27 | 28 | # The following directives appear in the core-graphql library, but are not supported by the 29 | # GraphQL compiler. 30 | unsupported_default_directives = frozenset( 31 | [ 32 | frozenset( 33 | [ 34 | "include", 35 | frozenset( 36 | [ 37 | DirectiveLocation.FIELD, 38 | DirectiveLocation.FRAGMENT_SPREAD, 39 | DirectiveLocation.INLINE_FRAGMENT, 40 | ] 41 | ), 42 | frozenset(["if"]), 43 | ] 44 | ), 45 | frozenset( 46 | [ 47 | "skip", 48 | frozenset( 49 | [ 50 | DirectiveLocation.FIELD, 51 | DirectiveLocation.FRAGMENT_SPREAD, 52 | DirectiveLocation.INLINE_FRAGMENT, 53 | ] 54 | ), 55 | frozenset(["if"]), 56 | ] 57 | ), 58 | ] 59 | ) 60 | 61 | # The following directives are supported and ignored by the compiler, 62 | # since they are meant to communicate user-facing information. 63 | supported_default_directives = frozenset( 64 | [ 65 | frozenset( 66 | [ 67 | "deprecated", 68 | frozenset([DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.ENUM_VALUE]), 69 | frozenset(["reason"]), 70 | ] 71 | ), 72 | frozenset( 73 | [ 74 | "specifiedBy", 75 | frozenset([DirectiveLocation.SCALAR]), 76 | frozenset(["url"]), 77 | ] 78 | ), 79 | ] 80 | ) 81 | 82 | # Directives expected by the graphql compiler. 83 | expected_directives = { 84 | frozenset( 85 | [ 86 | directive.name, 87 | frozenset(directive.locations), 88 | frozenset(six.viewkeys(directive.args)), 89 | ] 90 | ) 91 | for directive in DIRECTIVES 92 | } 93 | 94 | # Directives provided in the parsed graphql schema. 95 | actual_directives = { 96 | frozenset( 97 | [ 98 | directive.name, 99 | frozenset(directive.locations), 100 | frozenset(six.viewkeys(directive.args)), 101 | ] 102 | ) 103 | for directive in schema.directives 104 | } 105 | 106 | # Directives missing from the actual directives provided. 107 | missing_directives = expected_directives - actual_directives 108 | if missing_directives: 109 | missing_message = ( 110 | "The following directives were missing from the " 111 | "provided schema: {}".format(missing_directives) 112 | ) 113 | core_graphql_errors.append(missing_message) 114 | 115 | # Directives that are not specified by the core graphql library. Note that Graphql-core 116 | # automatically injects default directives into the schema, regardless of whether 117 | # the schema supports said directives. Hence, while the directives contained in 118 | # unsupported_default_directives are incompatible with the graphql-compiler, we allow them to 119 | # be present in the parsed schema string. 120 | extra_directives = ( 121 | actual_directives 122 | - expected_directives 123 | - unsupported_default_directives 124 | - supported_default_directives 125 | ) 126 | if extra_directives: 127 | extra_message = ( 128 | "The following directives were supplied in the given schema, but are not " 129 | "not supported by the GraphQL compiler: {}".format(extra_directives) 130 | ) 131 | core_graphql_errors.append(extra_message) 132 | 133 | return core_graphql_errors 134 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/workarounds/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/compiler/workarounds/orientdb_class_with_while.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | """Workarounds for OrientDB bug disallowing "class:" clauses together with "while:" clauses. 3 | 4 | For details, see: 5 | https://github.com/orientechnologies/orientdb/issues/8129 6 | """ 7 | from ..blocks import Recurse 8 | from ..ir_lowering_match.utils import convert_coerce_type_and_add_to_where_block 9 | 10 | 11 | def workaround_type_coercions_in_recursions(match_query): 12 | """Lower CoerceType blocks into Filter blocks within Recurse steps.""" 13 | # This step is required to work around an OrientDB bug that causes queries with both 14 | # "while:" and "class:" in the same query location to fail to parse correctly. 15 | # 16 | # This bug is reported upstream: https://github.com/orientechnologies/orientdb/issues/8129 17 | # 18 | # Instead of "class:", we use "INSTANCEOF" in the "where:" clause to get correct behavior. 19 | # However, we don't want to switch all coercions to this format, since the "class:" clause 20 | # provides valuable info to the MATCH query scheduler about how to schedule efficiently. 21 | new_match_traversals = [] 22 | 23 | for current_traversal in match_query.match_traversals: 24 | new_traversal = [] 25 | 26 | for match_step in current_traversal: 27 | new_match_step = match_step 28 | 29 | has_coerce_type = match_step.coerce_type_block is not None 30 | has_recurse_root = isinstance(match_step.root_block, Recurse) 31 | 32 | if has_coerce_type and has_recurse_root: 33 | new_where_block = convert_coerce_type_and_add_to_where_block( 34 | match_step.coerce_type_block, match_step.where_block 35 | ) 36 | new_match_step = match_step._replace( 37 | coerce_type_block=None, where_block=new_where_block 38 | ) 39 | 40 | new_traversal.append(new_match_step) 41 | 42 | new_match_traversals.append(new_traversal) 43 | 44 | return match_query._replace(match_traversals=new_match_traversals) 45 | -------------------------------------------------------------------------------- /graphql_compiler/cost_estimation/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | """Query cost estimator. 3 | 4 | Purpose 5 | ======= 6 | 7 | Compiled GraphQL queries are sometimes too expensive to execute, in two ways: 8 | - They return too many results: high *cardinality*. 9 | - They require too many operations: high *execution cost*. 10 | 11 | If these impractically-expensive queries are executed, they can overload our systems and cause the 12 | querying system along with all dependent systems to crash. 13 | 14 | In order to prevent this, we use schema information and graph statistics to estimate these two costs 15 | at the GraphQL level given a query and parameters. 16 | 17 | A separate module could then use these estimates to inform users about potentially expensive 18 | queries, do automatic paging of the query, or suggest additions of indexes that may improve 19 | performance. 20 | 21 | Estimating Cardinality 22 | ====================== 23 | 24 | The *cardinality* of a query is a rough measure of the query result size and is defined as the 25 | unfolded number of rows returned by the query. 26 | 27 | We estimate cardinality by estimating the number of *result sets* (sets of graph vertices that match 28 | with scopes in the query) found as the results are *expanded* (as we step through the query and 29 | create or discard result sets). 30 | 31 | Example: 32 | Given the query 33 | { 34 | Region { 35 | name @output(out_name: "region") 36 | in_TropicalCyclone_LandfallRegion { 37 | name @output(out_name: "cyclone") 38 | } 39 | in_Earthquake_AffectedRegion { 40 | name @output(out_name: "earthquake") 41 | } 42 | } 43 | } 44 | and a graph with 6 Regions, 12 TropicalCyclones each linked to some Region, and 2 Earthquakes 45 | each linked to some Region, we estimate cardinality as follows: 46 | 47 | First, find all 6 Regions. For each Region, assuming the 12 relevant TropicalCyclones are evenly 48 | distributed among the 6 Regions, we expect 12/6=2 TropicalCyclones connected to each Region. So, 49 | after *expanding* each Region (going through each one and finding connected TropicalCyclones), 50 | we expect 6*2=12 *result sets* (subgraphs of a Region vertex connected to a TropicalCyclone 51 | vertex). Next, we expect only 2/6=.33 result sets in the *subexpansion* associated with 52 | Earthquakes (expanding each Region looking just for Earthquakes). So of the 12 TropicalCyclone 53 | result sets, we expect 12*.33=4 complete result sets for the full query (i.e. the query has 54 | estimated cardinality of 4). 55 | 56 | Approach Details: 57 | Following this expansion model, we can think of queries as trees and find the number of expected 58 | result sets as we recursively traverse the tree (i.e. step through the expansion). 59 | 60 | Our calculation depends on two types of values: 61 | (1) The root result set count (e.g. the 6 Regions in the graph) 62 | (2) The expected result set count per parent (e.g. .33 Earthquake result sets per Region) 63 | 64 | Both can be calculated with graph counts for every type in the schema which must be externally 65 | provided. (1) can be looked up directly and (2) can be approximated as the number of 66 | parent-child edges divided up over parent vertices present in the graph. 67 | 68 | Type casting and directives can affect these calculations in many different ways. We naively 69 | handle type casting, as well as optional, fold, recurse, and some filter directives. Additional 70 | statistics can be recorded to improve the coverage and accuracy of these adjustments. 71 | 72 | TODOs 73 | ===== 74 | - Estimate execution cost by augmenting the cardinality calculation. 75 | - Add recurse handling. 76 | - Add additional statistics to improve directive coverage (e.g. histograms 77 | to better model more filter operations). 78 | """ 79 | -------------------------------------------------------------------------------- /graphql_compiler/cost_estimation/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from typing import Union 3 | 4 | from graphql import ( 5 | GraphQLInt, 6 | GraphQLInterfaceType, 7 | GraphQLList, 8 | GraphQLObjectType, 9 | GraphQLScalarType, 10 | ) 11 | 12 | from ..global_utils import is_same_type 13 | from ..schema import GraphQLDate, GraphQLDateTime 14 | from ..schema.schema_info import QueryPlanningSchemaInfo, UUIDOrdering 15 | 16 | 17 | def _get_property_field_type( 18 | schema_info: QueryPlanningSchemaInfo, vertex_name: str, field_name: str 19 | ) -> Union[GraphQLList, GraphQLScalarType]: 20 | """Get the GraphQL type of the property field on the specified vertex.""" 21 | vertex_type = schema_info.schema.get_type(vertex_name) 22 | if not isinstance(vertex_type, (GraphQLObjectType, GraphQLInterfaceType)): 23 | raise AssertionError( 24 | f"Found unexpected type for vertex {vertex_name}: {vertex_type} {type(vertex_type)}" 25 | ) 26 | return vertex_type.fields[field_name].type 27 | 28 | 29 | def is_datetime_field_type( 30 | schema_info: QueryPlanningSchemaInfo, vertex_name: str, field_name: str 31 | ) -> bool: 32 | """Return whether the field is of type GraphQLDateTime.""" 33 | return is_same_type( 34 | GraphQLDateTime, _get_property_field_type(schema_info, vertex_name, field_name) 35 | ) 36 | 37 | 38 | def is_date_field_type( 39 | schema_info: QueryPlanningSchemaInfo, vertex_name: str, field_name: str 40 | ) -> bool: 41 | """Return whether the field is of type GraphQLDate.""" 42 | return is_same_type(GraphQLDate, _get_property_field_type(schema_info, vertex_name, field_name)) 43 | 44 | 45 | def is_int_field_type( 46 | schema_info: QueryPlanningSchemaInfo, vertex_name: str, field_name: str 47 | ) -> bool: 48 | """Return whether the field is of type GraphQLInt.""" 49 | return is_same_type(GraphQLInt, _get_property_field_type(schema_info, vertex_name, field_name)) 50 | 51 | 52 | def is_uuid4_type(schema_info: QueryPlanningSchemaInfo, vertex_name: str, field_name: str) -> bool: 53 | """Return whether the field is a uniformly distributed uuid4 type.""" 54 | return field_name in schema_info.uuid4_field_info.get(vertex_name, {}) 55 | 56 | 57 | def get_uuid_ordering( 58 | schema_info: QueryPlanningSchemaInfo, vertex_name: str, field_name: str 59 | ) -> UUIDOrdering: 60 | """Return the ordering of the uuid4 field.""" 61 | ordering = schema_info.uuid4_field_info.get(vertex_name, {}).get(field_name) 62 | if ordering is None: 63 | raise AssertionError(f"{vertex_name}.{field_name} is not a uniform uuid4 field.") 64 | return ordering 65 | -------------------------------------------------------------------------------- /graphql_compiler/cost_estimation/interval.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020-present Kensho Technologies, LLC. 2 | from dataclasses import dataclass 3 | import datetime 4 | from typing import Generic, Optional, TypeVar 5 | 6 | 7 | IntervalDomain = TypeVar("IntervalDomain", int, str, datetime.date, datetime.datetime) 8 | 9 | 10 | @dataclass(eq=False, frozen=True) 11 | class Interval(Generic[IntervalDomain]): 12 | """Interval of IntervalDomain values. The ends are inclusive.""" 13 | 14 | lower_bound: Optional[IntervalDomain] 15 | upper_bound: Optional[IntervalDomain] 16 | 17 | def is_empty(self) -> bool: 18 | """Return whether the interval is empty.""" 19 | if self.lower_bound is None or self.upper_bound is None: 20 | return False 21 | return self.lower_bound > self.upper_bound 22 | 23 | def __eq__(self, other) -> bool: 24 | """Compare two intervals. Empty intervals are considered equal to each other.""" 25 | if self.is_empty() and other.is_empty(): 26 | return True 27 | return self.lower_bound == other.lower_bound and self.upper_bound == other.upper_bound 28 | 29 | 30 | def measure_int_interval(interval: Interval[int]) -> Optional[int]: 31 | """Return the size of the integer interval.""" 32 | if interval.lower_bound is None or interval.upper_bound is None: 33 | return None 34 | if interval.is_empty(): 35 | return 0 36 | return interval.upper_bound - interval.lower_bound + 1 37 | 38 | 39 | def _get_stronger_lower_bound( 40 | lower_bound_a: Optional[IntervalDomain], lower_bound_b: Optional[IntervalDomain] 41 | ) -> Optional[IntervalDomain]: 42 | """Return the larger bound of the two given lower bounds.""" 43 | stronger_lower_bound = None 44 | if lower_bound_a is not None and lower_bound_b is not None: 45 | stronger_lower_bound = max(lower_bound_a, lower_bound_b) 46 | elif lower_bound_a is not None: 47 | stronger_lower_bound = lower_bound_a 48 | elif lower_bound_b is not None: 49 | stronger_lower_bound = lower_bound_b 50 | 51 | return stronger_lower_bound 52 | 53 | 54 | def _get_stronger_upper_bound( 55 | upper_bound_a: Optional[IntervalDomain], upper_bound_b: Optional[IntervalDomain] 56 | ) -> Optional[IntervalDomain]: 57 | """Return the smaller bound of the two given upper bounds.""" 58 | stronger_upper_bound = None 59 | if upper_bound_a is not None and upper_bound_b is not None: 60 | stronger_upper_bound = min(upper_bound_a, upper_bound_b) 61 | elif upper_bound_a is not None: 62 | stronger_upper_bound = upper_bound_a 63 | elif upper_bound_b is not None: 64 | stronger_upper_bound = upper_bound_b 65 | 66 | return stronger_upper_bound 67 | 68 | 69 | def intersect_int_intervals(interval_a: Interval[int], interval_b: Interval[int]) -> Interval[int]: 70 | """Return the intersection of two Intervals.""" 71 | strong_lower_bound = _get_stronger_lower_bound(interval_a.lower_bound, interval_b.lower_bound) 72 | strong_upper_bound = _get_stronger_upper_bound(interval_a.upper_bound, interval_b.upper_bound) 73 | return Interval(strong_lower_bound, strong_upper_bound) 74 | -------------------------------------------------------------------------------- /graphql_compiler/debugging_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | import re 3 | 4 | import six 5 | 6 | 7 | def remove_custom_formatting(query: str) -> str: 8 | """Prepare the query string for pretty-printing by removing all unusual formatting.""" 9 | query = re.sub("[\n ]+", " ", query) 10 | return query.replace("( ", "(").replace(" )", ")") 11 | 12 | 13 | def pretty_print_gremlin(gremlin: str) -> str: 14 | """Return a human-readable representation of a gremlin command string.""" 15 | gremlin = remove_custom_formatting(gremlin) 16 | too_many_parts = re.split(r"([)}]|scatter)[ ]?\.", gremlin) 17 | 18 | # Put the ) and } back on. 19 | parts = [ 20 | too_many_parts[i] + too_many_parts[i + 1] 21 | for i in six.moves.xrange(0, len(too_many_parts) - 1, 2) 22 | ] 23 | parts.append(too_many_parts[-1]) 24 | 25 | # Put the . back on. 26 | for i in six.moves.xrange(1, len(parts)): 27 | parts[i] = "." + parts[i] 28 | 29 | indentation = 0 30 | indentation_increment = 4 31 | output = [] 32 | for current_part in parts: 33 | if any( 34 | [ 35 | current_part.startswith(".out"), 36 | current_part.startswith(".in"), 37 | current_part.startswith(".ifThenElse"), 38 | ] 39 | ): 40 | indentation += indentation_increment 41 | elif current_part.startswith(".back") or current_part.startswith(".optional"): 42 | indentation -= indentation_increment 43 | if indentation < 0: 44 | raise AssertionError("Indentation became negative: {}".format(indentation)) 45 | 46 | output.append((" " * indentation) + current_part) 47 | 48 | return "\n".join(output).strip() 49 | 50 | 51 | def pretty_print_match(match: str, parameterized: bool = True) -> str: 52 | """Return a human-readable representation of a parameterized MATCH query string.""" 53 | left_curly = "{{" if parameterized else "{" 54 | right_curly = "}}" if parameterized else "}" 55 | match = remove_custom_formatting(match) 56 | parts = re.split("({}|{})".format(left_curly, right_curly), match) 57 | 58 | inside_braces = False 59 | indent_size = 4 60 | indent = " " * indent_size 61 | 62 | output = [parts[0]] 63 | for current_index, current_part in enumerate(parts[1:]): 64 | if current_part == left_curly: 65 | if inside_braces: 66 | raise AssertionError( 67 | "Found open-braces pair while already inside braces: " 68 | "{} {} {}".format(current_index, parts, match) 69 | ) 70 | inside_braces = True 71 | output.append(current_part + "\n") 72 | elif current_part == right_curly: 73 | if not inside_braces: 74 | raise AssertionError( 75 | "Found close-braces pair while not inside braces: " 76 | "{} {} {}".format(current_index, parts, match) 77 | ) 78 | inside_braces = False 79 | output.append(current_part) 80 | else: 81 | if not inside_braces: 82 | stripped_part = current_part.lstrip() 83 | if stripped_part.startswith("."): 84 | # Strip whitespace before traversal steps. 85 | output.append(stripped_part) 86 | else: 87 | # Do not strip whitespace before e.g. the RETURN keyword. 88 | output.append(current_part) 89 | else: 90 | # Split out the keywords, initially getting rid of commas. 91 | separate_keywords = re.split(", ([a-z]+:)", current_part) 92 | 93 | # The first item in the separated list is the full first "keyword: value" pair. 94 | # For every subsequent item, the keyword and value are separated; join them 95 | # back together, outputting the comma, newline and indentation before them. 96 | output.append(indent + separate_keywords[0].lstrip()) 97 | for i in six.moves.xrange(1, len(separate_keywords) - 1, 2): 98 | output.append( 99 | ",\n{indent}{keyword} {value}".format( 100 | keyword=separate_keywords[i].strip(), 101 | value=separate_keywords[i + 1].strip(), 102 | indent=indent, 103 | ) 104 | ) 105 | output.append("\n") 106 | 107 | return "".join(output).strip() 108 | -------------------------------------------------------------------------------- /graphql_compiler/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | class GraphQLError(Exception): 3 | """Generic error when processing GraphQL.""" 4 | 5 | 6 | class GraphQLParsingError(GraphQLError): 7 | """Exception raised when the provided GraphQL string could not be parsed.""" 8 | 9 | 10 | class GraphQLValidationError(GraphQLError): 11 | """Exception raised when the provided GraphQL does not validate against the provided schema.""" 12 | 13 | 14 | class GraphQLInvalidMacroError(GraphQLError): 15 | """Exception raised when the provided GraphQL macro fails to adhere to macro requirements.""" 16 | 17 | 18 | class GraphQLCompilationError(GraphQLError): 19 | """Exception raised when the provided GraphQL cannot be compiled. 20 | 21 | This could be due to many reasons, such as: 22 | - the GraphQL has more than one root selection; 23 | - the GraphQL has directives in unsupported locations, e.g. vertex-only directive on property; 24 | - the GraphQL provides invalid / disallowed / wrong number of arguments. 25 | """ 26 | 27 | 28 | class GraphQLInvalidArgumentError(GraphQLError): 29 | """Exception raised when the arguments to a GraphQL query are invalid. 30 | 31 | For example: 32 | - there may be unexpected arguments; 33 | - expected arguments may be missing; 34 | - an argument may be of incorrect type (e.g. expected an int but received a string). 35 | """ 36 | -------------------------------------------------------------------------------- /graphql_compiler/global_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | from dataclasses import dataclass 3 | from typing import Any, Dict, NamedTuple, Set, Tuple, Type, TypeVar 4 | 5 | from graphql import DocumentNode, GraphQLList, GraphQLNamedType, GraphQLNonNull, GraphQLType 6 | from graphql.language.printer import print_ast 7 | import six 8 | 9 | from .ast_manipulation import safe_parse_graphql 10 | 11 | 12 | # Imported from here, to avoid spreading the conditional import everywhere. 13 | try: 14 | from functools import cached_property # noqa # pylint: disable=unused-import 15 | except ImportError: 16 | from backports.cached_property import cached_property # type: ignore[no-redef] # noqa 17 | 18 | 19 | # A path starting with a vertex and continuing with edges from that vertex 20 | VertexPath = Tuple[str, ...] 21 | 22 | 23 | class PropertyPath(NamedTuple): 24 | """A VertexPath with a property on the final vertex of the path.""" 25 | 26 | vertex_path: VertexPath 27 | field_name: str 28 | 29 | 30 | QueryStringWithParametersT = TypeVar( 31 | "QueryStringWithParametersT", bound="QueryStringWithParameters" 32 | ) 33 | ASTWithParametersT = TypeVar("ASTWithParametersT", bound="ASTWithParameters") 34 | 35 | 36 | @dataclass 37 | class QueryStringWithParameters: 38 | """A query string and parameters that validate against the query.""" 39 | 40 | query_string: str 41 | parameters: Dict[str, Any] 42 | 43 | @classmethod 44 | def from_ast_with_parameters( 45 | cls: Type[QueryStringWithParametersT], ast_with_params: "ASTWithParameters" 46 | ) -> QueryStringWithParametersT: 47 | """Convert an ASTWithParameters into its equivalent QueryStringWithParameters form.""" 48 | query_string = print_ast(ast_with_params.query_ast) 49 | return cls(query_string, ast_with_params.parameters) 50 | 51 | 52 | @dataclass 53 | class ASTWithParameters: 54 | """A query AST and parameters that validate against the query.""" 55 | 56 | query_ast: DocumentNode 57 | parameters: Dict[str, Any] 58 | 59 | @classmethod 60 | def from_query_string_with_parameters( 61 | cls: Type[ASTWithParametersT], query_with_params: QueryStringWithParameters 62 | ) -> ASTWithParametersT: 63 | """Convert an QueryStringWithParameters into its equivalent ASTWithParameters form.""" 64 | query_ast = safe_parse_graphql(query_with_params.query_string) 65 | return cls(query_ast, query_with_params.parameters) 66 | 67 | 68 | KT = TypeVar("KT") 69 | VT = TypeVar("VT") 70 | 71 | 72 | def merge_non_overlapping_dicts(merge_target: Dict[KT, VT], new_data: Dict[KT, VT]) -> Dict[KT, VT]: 73 | """Produce the merged result of two dicts that are supposed to not overlap.""" 74 | result = dict(merge_target) 75 | 76 | for key, value in six.iteritems(new_data): 77 | if key in merge_target: 78 | raise AssertionError( 79 | 'Overlapping key "{}" found in dicts that are supposed ' 80 | "to not overlap. Values: {} {}".format(key, merge_target[key], value) 81 | ) 82 | 83 | result[key] = value 84 | 85 | return result 86 | 87 | 88 | def is_same_type(left: GraphQLType, right: GraphQLType) -> bool: 89 | """Determine if two GraphQL types are the same type.""" 90 | if isinstance(left, GraphQLNamedType) and isinstance(right, GraphQLNamedType): 91 | return left.__class__ is right.__class__ and left.name == right.name 92 | elif isinstance(left, GraphQLList) and isinstance(right, GraphQLList): 93 | return is_same_type(left.of_type, right.of_type) 94 | elif isinstance(left, GraphQLNonNull) and isinstance(right, GraphQLNonNull): 95 | return is_same_type(left.of_type, right.of_type) 96 | else: 97 | return False 98 | 99 | 100 | def assert_set_equality(set1: Set[Any], set2: Set[Any]) -> None: 101 | """Assert that the sets are the same.""" 102 | diff1 = set1.difference(set2) 103 | diff2 = set2.difference(set1) 104 | 105 | if diff1 or diff2: 106 | error_message_list = ["Expected sets to have the same keys."] 107 | if diff1: 108 | error_message_list.append(f"Keys in the first set but not the second: {diff1}.") 109 | if diff2: 110 | error_message_list.append(f"Keys in the second set but not the first: {diff2}.") 111 | raise AssertionError(" ".join(error_message_list)) 112 | -------------------------------------------------------------------------------- /graphql_compiler/interpreter/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020-present Kensho Technologies, LLC. 2 | """Tools for constructing high-performance query interpreters over arbitrary schemas. 3 | 4 | While GraphQL compiler's database querying capabilities are sufficient for many use cases, there are 5 | many types of data querying for which the compilation-based approach is unsuitable. A few examples: 6 | - data accessible via a simple API instead of a rich query language, 7 | - data represented as a set of files and directories on a local disk, 8 | - data produced on-demand by running a machine learning model over some inputs. 9 | 10 | The data in each of these cases can be described by a valid schema, and users could write 11 | well-defined and legal queries against that schema. However, the execution of such queries cannot 12 | proceed by compiling them to another query language -- no such target query language exists. 13 | Instead, the queries need to be executed using an *interpreter*: a piece of code 14 | that executes queries incrementally in a series of steps, such as "fetch the value of this field" 15 | or "filter out this data point if its value is less than 5." 16 | 17 | Some parts of the interpreter (e.g. "fetch the value of this field") obviously need to be aware of 18 | the schema and the underlying data source. Other parts (e.g. "filter out this data point") are 19 | schema-agnostic -- they work in the same way regardless of the schema and data source. This library 20 | provides efficient implementations of all schema-agnostic interpreter components. All schema-aware 21 | logic is abstracted into the straightforward, four-method API of the InterpreterAdapter class, 22 | which should be subclassed to create a new interpreter over a new dataset. 23 | 24 | As a result, the development of a new interpreter looks like this: 25 | - Construct the schema of the data your new interpreter will be querying. 26 | - Construct a subclass InterpreterAdapter class -- let's call it MyCustomAdapter. 27 | - Add long-lived interpreter state such as API keys, connection pools, etc. as instance attributes 28 | of the MyCustomAdapter class. 29 | - Implement the four simple functions that form the InterpreterAdapter API. 30 | - Construct an instance of MyCustomAdapter and pass it to the schema-agnostic portion of 31 | the interpreter implemented in this library, such as the interpret_ir() function. 32 | - You now have a way to execute queries over your schema! Then, profit! 33 | 34 | For more information, consult the documentation of the items exported below. 35 | """ 36 | -------------------------------------------------------------------------------- /graphql_compiler/interpreter/immutable_stack.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020-present Kensho Technologies, LLC. 2 | from copy import deepcopy 3 | from dataclasses import dataclass 4 | from typing import Any, Dict, Optional, Tuple 5 | 6 | 7 | @dataclass(frozen=True, init=False) 8 | class ImmutableStack: 9 | """An immutable stack of arbitrary (heterogeneously-typed) values. 10 | 11 | Specifically designed for cheap structural sharing, in order to avoid deep copies or 12 | bugs caused by mutations of shared data. 13 | """ 14 | 15 | __slots__ = ("value", "depth", "tail") 16 | 17 | # The following attributes are considered visible and safe for direct external use. 18 | # 19 | # N.B.: Keep "depth" defined before "tail"! The default "==" implementation for dataclasses 20 | # compares instances as if they were tuples with their attribute values in the order in 21 | # which they were declared. The "depth" is much cheaper to check for equality, so it must 22 | # be checked first. 23 | value: Any # The value contained within this stack node. 24 | depth: int # The number of stack nodes contained in the tail of the stack. 25 | tail: Optional["ImmutableStack"] # The node that represents the rest of the stack, if any. 26 | 27 | def __init__(self, value: Any, tail: Optional["ImmutableStack"]) -> None: 28 | """Initialize the ImmutableStack.""" 29 | # Per the docs, frozen dataclasses use object.__setattr__() to write their attributes. 30 | # We have to do that too since we are dynamically setting the "depth" attribute. 31 | # Docs link: https://docs.python.org/3/library/dataclasses.html#frozen-instances 32 | object.__setattr__(self, "value", value) 33 | object.__setattr__(self, "tail", tail) 34 | 35 | depth = 0 if tail is None else tail.depth + 1 36 | object.__setattr__(self, "depth", depth) 37 | 38 | def __copy__(self) -> "ImmutableStack": 39 | """Produce a shallow copy of the ImmutableStack. Required because depth is not settable.""" 40 | return ImmutableStack(self.value, self.tail) 41 | 42 | def __deepcopy__(self, memo: Optional[Dict[int, Any]]) -> "ImmutableStack": 43 | """Produce a deep copy of the ImmutableStack. Required because depth is not settable.""" 44 | # ImmutableStack objects cannot have reference cycles among each other, 45 | # so we don't need to check the memo dict or write to it. 46 | value_copy = deepcopy(self.value, memo) 47 | tail_copy = deepcopy(self.tail, memo) 48 | return ImmutableStack(value_copy, tail_copy) 49 | 50 | def push(self, value: Any) -> "ImmutableStack": 51 | """Create a new ImmutableStack with the given value at its top.""" 52 | return ImmutableStack(value, self) 53 | 54 | def pop(self) -> Tuple[Any, Optional["ImmutableStack"]]: 55 | """Return a tuple with the topmost value and a node for the rest of the stack, if any.""" 56 | return (self.value, self.tail) 57 | 58 | 59 | def make_empty_stack() -> ImmutableStack: 60 | """Create a new empty stack, with initial value None at its bottom level. 61 | 62 | Using an explicit None at the bottom of the stack allows us to eliminate some None checks, since 63 | pushing N elements, then popping N elements still leaves us with an ImmutableStack instance as 64 | the tail (remainder) of the stack, instead of the None tail we'd get otherwise. 65 | """ 66 | return ImmutableStack(None, None) 67 | -------------------------------------------------------------------------------- /graphql_compiler/macros/macro_edge/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from ...ast_manipulation import get_only_query_definition, safe_parse_graphql 3 | from ...exceptions import GraphQLInvalidMacroError 4 | from .validation import get_and_validate_macro_edge_info 5 | 6 | 7 | def make_macro_edge_descriptor( 8 | schema, subclass_sets, macro_edge_graphql, macro_edge_args, type_equivalence_hints=None 9 | ): 10 | """Validate the GraphQL macro edge definition, and return a MacroEdgeDescriptor describing it. 11 | 12 | Args: 13 | schema: GraphQL schema object, created using the GraphQL library 14 | subclass_sets: Dict[str, Set[str]] mapping class names to the set of its subclass names. 15 | A class in this context means the name of a GraphQLObjectType, 16 | GraphQLUnionType or GraphQLInterface. 17 | macro_edge_graphql: string, GraphQL defining how the new macro edge should be expanded 18 | macro_edge_args: dict mapping strings to any type, containing any arguments the macro edge 19 | requires in order to function. 20 | type_equivalence_hints: optional dict of GraphQL interface or type -> GraphQL union. 21 | Used as a workaround for GraphQL's lack of support for 22 | inheritance across "types" (i.e. non-interfaces), as well as a 23 | workaround for Gremlin's total lack of inheritance-awareness. 24 | The key-value pairs in the dict specify that the "key" type 25 | is equivalent to the "value" type, i.e. that the GraphQL type or 26 | interface in the key is the most-derived common supertype 27 | of every GraphQL type in the "value" GraphQL union. 28 | Recursive expansion of type equivalence hints is not performed, 29 | and only type-level correctness of this argument is enforced. 30 | See README.md for more details on everything this parameter does. 31 | ***** 32 | Be very careful with this option, as bad input here will 33 | lead to incorrect output queries being generated. 34 | ***** 35 | 36 | Returns: 37 | MacroEdgeDescriptor suitable for inclusion into the GraphQL macro registry 38 | """ 39 | root_ast = safe_parse_graphql(macro_edge_graphql) 40 | 41 | definition_ast = get_only_query_definition(root_ast, GraphQLInvalidMacroError) 42 | 43 | macro_edge_descriptor = get_and_validate_macro_edge_info( 44 | schema, 45 | subclass_sets, 46 | definition_ast, 47 | macro_edge_args, 48 | type_equivalence_hints=type_equivalence_hints, 49 | ) 50 | 51 | return macro_edge_descriptor 52 | -------------------------------------------------------------------------------- /graphql_compiler/macros/macro_edge/ast_traversal.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | """Read-only helpers for traversing AST objects.""" 3 | from graphql import GraphQLList 4 | from graphql.language.ast import FieldNode, InlineFragmentNode, OperationDefinitionNode 5 | 6 | from ...ast_manipulation import get_ast_field_name 7 | from ...compiler.helpers import get_field_type_from_schema, get_vertex_field_type 8 | from ...schema import TagDirective 9 | from ..macro_edge.directives import MacroEdgeTargetDirective 10 | 11 | 12 | def _yield_ast_nodes_with_directives(ast): 13 | """Yield the AST objects where directives appear, anywhere in the given AST. 14 | 15 | Args: 16 | ast: GraphQL library AST object, such as a Field, InlineFragment, or OperationDefinition 17 | 18 | Yields: 19 | Iterable[Tuple[AST object, Directive]], where each tuple describes an AST node together with 20 | the directive it contains. If an AST node contains multiple directives, the AST node will be 21 | returned as part of multiple tuples, in no particular order. 22 | """ 23 | for directive in ast.directives: 24 | yield (ast, directive) 25 | 26 | if isinstance(ast, (FieldNode, InlineFragmentNode, OperationDefinitionNode)): 27 | if ast.selection_set is not None: 28 | for sub_selection_set in ast.selection_set.selections: 29 | # TODO(predrag): When we make the compiler py3-only, use a "yield from" here. 30 | for entry in _yield_ast_nodes_with_directives(sub_selection_set): 31 | yield entry 32 | else: 33 | raise AssertionError("Unexpected AST type received: {} {}".format(type(ast), ast)) 34 | 35 | 36 | def _get_type_at_macro_edge_target_using_current_type(schema, ast, current_type): 37 | """Return the type at the @macro_edge_target or None if there is no target.""" 38 | # Base case 39 | for directive in ast.directives: 40 | if directive.name.value == MacroEdgeTargetDirective.name: 41 | return current_type 42 | 43 | if not isinstance(ast, (FieldNode, InlineFragmentNode, OperationDefinitionNode)): 44 | raise AssertionError("Unexpected AST type received: {} {}".format(type(ast), ast)) 45 | 46 | # Recurse 47 | if ast.selection_set is not None: 48 | for selection in ast.selection_set.selections: 49 | type_in_selection = None 50 | if isinstance(selection, FieldNode): 51 | if selection.selection_set is not None: 52 | type_in_selection = get_vertex_field_type(current_type, selection.name.value) 53 | elif isinstance(selection, InlineFragmentNode): 54 | type_in_selection = schema.get_type(selection.type_condition.name.value) 55 | else: 56 | raise AssertionError( 57 | "Unexpected selection type received: {} {}".format(type(selection), selection) 58 | ) 59 | 60 | if type_in_selection is not None: 61 | type_at_target = _get_type_at_macro_edge_target_using_current_type( 62 | schema, selection, type_in_selection 63 | ) 64 | if type_at_target is not None: 65 | return type_at_target 66 | 67 | return None # Didn't find target 68 | 69 | 70 | # ############ 71 | # Public API # 72 | # ############ 73 | 74 | 75 | def get_directives_for_ast(ast): 76 | """Return a dict of directive name -> list of (ast, directive) where that directive is used. 77 | 78 | Args: 79 | ast: GraphQL library AST object, such as a Field, InlineFragment, or OperationDefinition 80 | 81 | Returns: 82 | Dict[str, List[Tuple[AST object, Directive]]], allowing the user to find the instances 83 | in this AST object where a directive with a given name appears; for each of those instances, 84 | we record and return the AST object where the directive was applied, together with the AST 85 | Directive object describing it together with any arguments that might have been supplied. 86 | """ 87 | result = {} 88 | 89 | for ast, directive in _yield_ast_nodes_with_directives(ast): 90 | directive_name = directive.name.value 91 | result.setdefault(directive_name, []).append((ast, directive)) 92 | 93 | return result 94 | 95 | 96 | def get_all_tag_names(ast): 97 | """Return a set of strings containing tag names that appear in the query. 98 | 99 | Args: 100 | ast: GraphQL query AST object 101 | 102 | Returns: 103 | set of strings containing tag names that appear in the query 104 | """ 105 | return { 106 | # Schema validation has ensured this exists 107 | directive.arguments[0].value.value 108 | for ast, directive in _yield_ast_nodes_with_directives(ast) 109 | if directive.name.value == TagDirective.name 110 | } 111 | 112 | 113 | def get_type_at_macro_edge_target(schema, ast): 114 | """Return the GraphQL type at the @macro_edge_target or None if there is no target.""" 115 | root_type = get_ast_field_name(ast) 116 | root_schema_type = get_field_type_from_schema(schema.query_type, root_type) 117 | 118 | # Allow list types at the query root in the schema. 119 | if isinstance(root_schema_type, GraphQLList): 120 | root_schema_type = root_schema_type.of_type 121 | 122 | return _get_type_at_macro_edge_target_using_current_type(schema, ast, root_schema_type) 123 | -------------------------------------------------------------------------------- /graphql_compiler/macros/macro_edge/descriptor.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from collections import namedtuple 3 | 4 | from ...schema import is_vertex_field_name 5 | from .ast_rewriting import remove_directives_from_ast 6 | from .directives import MacroEdgeDefinitionDirective 7 | 8 | 9 | MacroEdgeDescriptor = namedtuple( 10 | "MacroEdgeDescriptor", 11 | ( 12 | "base_class_name", # str, name of GraphQL type where the macro edge is defined. 13 | # The macro edge exists at this type and all of its subtypes. 14 | "target_class_name", # str, the name of the GraphQL type that the macro edge points to. 15 | "macro_edge_name", # str, name of the vertex field corresponding to this macro edge. 16 | # Should start with "out_" or "in_", per GraphQL compiler convention. 17 | "expansion_ast", # GraphQL AST object defining how the macro edge 18 | # should be expanded starting from its base type. The 19 | # selections must be merged (on both endpoints of the 20 | # macro edge) with the user-supplied GraphQL input. 21 | "macro_args", # Dict[str, Any] containing any arguments required by the macro 22 | ), 23 | ) 24 | 25 | 26 | def create_descriptor_from_ast_and_args( 27 | class_name, target_class_name, macro_edge_name, macro_definition_ast, macro_edge_args 28 | ): 29 | """Remove macro edge definition directive, and return a MacroEdgeDescriptor.""" 30 | if not is_vertex_field_name(macro_edge_name): 31 | raise AssertionError("Received illegal macro edge name: {}".format(macro_edge_name)) 32 | 33 | directives_to_remove = {MacroEdgeDefinitionDirective.name} 34 | new_ast = remove_directives_from_ast(macro_definition_ast, directives_to_remove) 35 | return MacroEdgeDescriptor( 36 | class_name, target_class_name, macro_edge_name, new_ast, macro_edge_args 37 | ) 38 | -------------------------------------------------------------------------------- /graphql_compiler/macros/macro_edge/directives.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from itertools import chain 3 | 4 | from graphql.type import GraphQLSchema 5 | 6 | from ...schema import ( 7 | FilterDirective, 8 | FoldDirective, 9 | MacroEdgeDefinitionDirective, 10 | MacroEdgeDirective, 11 | MacroEdgeTargetDirective, 12 | OptionalDirective, 13 | RecurseDirective, 14 | TagDirective, 15 | check_for_nondefault_directive_names, 16 | ) 17 | 18 | 19 | # Directives reserved for macro edges 20 | MACRO_EDGE_DIRECTIVES = ( 21 | MacroEdgeDirective, 22 | MacroEdgeDefinitionDirective, 23 | MacroEdgeTargetDirective, 24 | ) 25 | 26 | # Names of directives required present in a macro edge definition 27 | DIRECTIVES_REQUIRED_IN_MACRO_EDGE_DEFINITION = frozenset( 28 | {MacroEdgeDefinitionDirective.name, MacroEdgeTargetDirective.name} 29 | ) 30 | 31 | # Names of directives allowed within a macro edge definition 32 | DIRECTIVES_ALLOWED_IN_MACRO_EDGE_DEFINITION = frozenset( 33 | { 34 | FoldDirective.name, 35 | FilterDirective.name, 36 | OptionalDirective.name, 37 | TagDirective.name, 38 | RecurseDirective.name, 39 | }.union(DIRECTIVES_REQUIRED_IN_MACRO_EDGE_DEFINITION) 40 | ) 41 | 42 | 43 | def get_schema_for_macro_edge_definitions(querying_schema): 44 | """Given a schema object used for querying, create a schema used for macro edge definitions.""" 45 | original_directives = querying_schema.directives 46 | check_for_nondefault_directive_names(original_directives) 47 | 48 | directives_required_in_macro_edge_definition = [ 49 | MacroEdgeDefinitionDirective, 50 | MacroEdgeTargetDirective, 51 | ] 52 | 53 | new_directives = [ 54 | directive 55 | for directive in chain(original_directives, directives_required_in_macro_edge_definition) 56 | if directive.name in DIRECTIVES_ALLOWED_IN_MACRO_EDGE_DEFINITION 57 | ] 58 | 59 | schema_arguments = querying_schema.to_kwargs() 60 | schema_arguments["directives"] = new_directives 61 | macro_edge_schema = GraphQLSchema(**schema_arguments) 62 | 63 | return macro_edge_schema 64 | -------------------------------------------------------------------------------- /graphql_compiler/macros/macro_edge/name_generation.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | 3 | 4 | def generate_disambiguations(existing_names, new_names): 5 | """Return a dict mapping the new names to similar names not conflicting with existing names. 6 | 7 | We always try to keep names the same if possible, and only generate name changes if the desired 8 | name is already taken. 9 | 10 | Args: 11 | existing_names: set of strings, the names that are already taken 12 | new_names: set of strings, the names that might coincide with existing names 13 | 14 | Returns: 15 | dict mapping the new names to other unique names not present in existing_names 16 | """ 17 | name_mapping = dict() 18 | for name in new_names: 19 | # We try adding different suffixes to disambiguate from the existing names. 20 | disambiguation = name 21 | index = 0 22 | while disambiguation in existing_names or disambiguation in name_mapping: 23 | disambiguation = "{}_macro_edge_{}".format(name, index) 24 | index += 1 25 | name_mapping[name] = disambiguation 26 | return name_mapping 27 | -------------------------------------------------------------------------------- /graphql_compiler/macros/macro_edge/reversal.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from ...schema import INBOUND_EDGE_FIELD_PREFIX, OUTBOUND_EDGE_FIELD_PREFIX 3 | 4 | 5 | # ############ 6 | # Public API # 7 | # ############ 8 | 9 | 10 | def make_reverse_macro_edge_name(macro_edge_name): 11 | """Autogenerate a reverse macro edge name for the given macro edge name.""" 12 | if macro_edge_name.startswith(INBOUND_EDGE_FIELD_PREFIX): 13 | raw_edge_name = macro_edge_name[len(INBOUND_EDGE_FIELD_PREFIX) :] 14 | prefix = OUTBOUND_EDGE_FIELD_PREFIX 15 | elif macro_edge_name.startswith(OUTBOUND_EDGE_FIELD_PREFIX): 16 | raw_edge_name = macro_edge_name[len(OUTBOUND_EDGE_FIELD_PREFIX) :] 17 | prefix = INBOUND_EDGE_FIELD_PREFIX 18 | else: 19 | raise AssertionError("Unreachable condition reached: {}".format(macro_edge_name)) 20 | 21 | reversed_macro_edge_name = prefix + raw_edge_name 22 | 23 | return reversed_macro_edge_name 24 | -------------------------------------------------------------------------------- /graphql_compiler/post_processing/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/post_processing/sql_post_processing.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | import html 3 | import re 4 | from typing import Any, Dict, List, Optional, Sequence 5 | 6 | from graphql import GraphQLList, GraphQLScalarType 7 | 8 | from ..compiler.compiler_frontend import OutputMetadata 9 | from ..deserialization import deserialize_scalar_value 10 | 11 | 12 | def _mssql_xml_path_string_to_list( 13 | xml_path_result: str, list_entry_type: GraphQLScalarType 14 | ) -> List[Any]: 15 | """Convert the string result produced with XML PATH for MSSQL folds to a list. 16 | 17 | Args: 18 | xml_path_result: str, result from an XML PATH folded output 19 | list_entry_type: GraphQLScalarType, type the results should be output as 20 | 21 | Returns: 22 | list representation of the result with all XML and GraphQL Compiler escaping reversed 23 | """ 24 | # Return an empty list if the XML PATH result is "". 25 | if xml_path_result == "": 26 | return [] 27 | 28 | # Some of the special characters involved in XML path array aggregation. 29 | delimiter = "|" 30 | null = "~" 31 | 32 | # Remove the "|" from the first result in the string representation of the list. 33 | if xml_path_result[0] != delimiter: 34 | raise AssertionError( 35 | f"Unexpected fold result. All XML path array aggregated lists must start with a " 36 | f"'{delimiter}'. Received a result beginning with '{xml_path_result[0]}': " 37 | f"{xml_path_result}" 38 | ) 39 | xml_path_result = xml_path_result[1:] 40 | 41 | # Split the XML path result on "|". 42 | list_result: Sequence[Optional[str]] = xml_path_result.split(delimiter) 43 | 44 | # Convert "~" to None. Note that this must be done before "^n" -> "~". 45 | list_result = [None if result == null else result for result in list_result] 46 | 47 | # Convert "^d" to "|". 48 | list_result = [ 49 | result.replace("^d", delimiter) if result is not None else None for result in list_result 50 | ] 51 | 52 | # Convert "^n" to "~". 53 | list_result = [ 54 | result.replace("^n", null) if result is not None else None for result in list_result 55 | ] 56 | 57 | # Convert "^e" to "^". Note that this must be done after the caret escaped characters i.e. 58 | # after "^n" -> "~" and "^d" -> "|". 59 | list_result = [ 60 | result.replace("^e", "^") if result is not None else None for result in list_result 61 | ] 62 | 63 | # Convert "&#x{2 digit HEX};" to unicode character. 64 | new_list_result: List[Optional[str]] = [] 65 | for result in list_result: 66 | if result is not None: 67 | split_result = re.split("&#x([A-Fa-f0-9][A-Fa-f0-9]);", result) 68 | new_result = split_result[0] 69 | for hex_value, next_substring in zip(split_result[1::2], split_result[2::2]): 70 | new_result += chr(int(hex_value, 16)) + next_substring 71 | new_list_result.append(new_result) 72 | else: 73 | new_list_result.append(None) 74 | 75 | # Convert "&" to "&", ">" to ">", "<" to "<". Note that the ampersand conversion 76 | # must be done after the ampersand escaped HEX values. 77 | list_result = [ 78 | html.unescape(result) if result is not None else None for result in new_list_result 79 | ] 80 | 81 | # Convert to the appropriate return type. 82 | list_result_to_return: List[Optional[Any]] = [ 83 | deserialize_scalar_value(list_entry_type, result) if result is not None else None 84 | for result in list_result 85 | ] 86 | 87 | return list_result_to_return 88 | 89 | 90 | def post_process_mssql_folds( 91 | query_results: List[Dict[str, Any]], output_metadata: Dict[str, OutputMetadata] 92 | ) -> None: 93 | r"""Convert XML PATH fold results from a string to a list of the appropriate type. 94 | 95 | See _get_xml_path_clause in graphql_compiler/compiler/emit_sql.py for an in-depth description 96 | of the encoding process. 97 | 98 | Post-processing steps: 99 | 1. split on "|", 100 | 2. convert "~" to None 101 | 3. convert caret escaped characters (excluding "^" itself) 102 | i.e. "^d" (delimiter) to "|" and "^n" (null) to "~" 103 | 4. with caret escaped characters removed, convert "^e" to "^" 104 | 5. convert ampersand escaped characters (excluding "&" itself) 105 | i.e. ">" to ">", "<" to "<" and "&#xHEX;" to "\xHEX" 106 | 6. with ampersand escaped characters removed, convert "&" to "&" 107 | 108 | Args: 109 | query_results: Dict[str, Any], results from graphql_query being run with schema_info, 110 | mutated in place 111 | output_metadata: Dict[str, OutputMetadata], mapping output name to output metadata with 112 | information about whether this output is from a fold scope 113 | 114 | """ 115 | for out_name, metadata in output_metadata.items(): 116 | # If this output is folded and has type GraphQLList (i.e. it is not an _x_count), 117 | # post-process the result to list form. 118 | if metadata.folded and isinstance(metadata.type, GraphQLList): 119 | for query_result in query_results: 120 | xml_path_result = query_result[out_name] 121 | list_result = _mssql_xml_path_string_to_list(xml_path_result, metadata.type.of_type) 122 | query_result[out_name] = list_result 123 | -------------------------------------------------------------------------------- /graphql_compiler/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kensho-technologies/graphql-compiler/4318443b7b2512a059f3616112bfc40bbf8eec06/graphql_compiler/py.typed -------------------------------------------------------------------------------- /graphql_compiler/query_formatting/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | """Safely insert runtime arguments into compiled GraphQL queries.""" 3 | from .common import insert_arguments_into_query, validate_argument_type # noqa 4 | -------------------------------------------------------------------------------- /graphql_compiler/query_formatting/graphql_formatting.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | from graphql import parse 3 | from graphql.language.printer import PrintAstVisitor, join, wrap 4 | from graphql.language.visitor import visit 5 | import six 6 | 7 | from ..schema import DIRECTIVES 8 | 9 | 10 | def pretty_print_graphql(query, use_four_spaces=True): 11 | """Take a GraphQL query, pretty print it, and return it.""" 12 | # Use our custom visitor, which fixes directive argument order 13 | # to get the canonical representation 14 | output = visit(parse(query), CustomPrintingVisitor()) 15 | 16 | # Using four spaces for indentation makes it easier to edit in 17 | # Python source files. 18 | if use_four_spaces: 19 | return fix_indentation_depth(output) 20 | return output 21 | 22 | 23 | DIRECTIVES_BY_NAME = {d.name: d for d in DIRECTIVES} 24 | 25 | 26 | class CustomPrintingVisitor(PrintAstVisitor): 27 | # Directives are easier to read if their arguments appear in the order in 28 | # which we defined them in the schema. For example, @filter directives are 29 | # much easier to read if the operation comes before the values. The 30 | # arguments of the directives specified in the schema are defined as 31 | # OrderedDicts which allows us to sort the provided arguments to match. 32 | def leave_directive(self, node, *args): 33 | """Call when exiting a directive node in the ast.""" 34 | name_to_arg_value = { 35 | # Taking [0] is ok here because the GraphQL parser checks for the 36 | # existence of ':' in directive arguments. 37 | arg.split(":", 1)[0]: arg 38 | for arg in node.arguments 39 | } 40 | 41 | ordered_args = node.arguments 42 | directive = DIRECTIVES_BY_NAME.get(node.name) 43 | if directive: 44 | sorted_args = [] 45 | encountered_argument_names = set() 46 | 47 | # Iterate through all defined arguments in the directive schema. 48 | for defined_arg_name in six.iterkeys(directive.args): 49 | if defined_arg_name in name_to_arg_value: 50 | # The argument was present in the query, print it in the correct order. 51 | encountered_argument_names.add(defined_arg_name) 52 | sorted_args.append(name_to_arg_value[defined_arg_name]) 53 | 54 | # Get all the arguments that weren't defined in the directive schema. 55 | # They will be printed after all the arguments that were in the schema. 56 | unsorted_args = [ 57 | value 58 | for name, value in six.iteritems(name_to_arg_value) 59 | if name not in encountered_argument_names 60 | ] 61 | 62 | ordered_args = sorted_args + unsorted_args 63 | 64 | return "@" + node.name + wrap("(", join(ordered_args, ", "), ")") 65 | 66 | 67 | def fix_indentation_depth(query): 68 | """Make indentation use 4 spaces, rather than the 2 spaces GraphQL normally uses.""" 69 | lines = query.split("\n") 70 | final_lines = [] 71 | 72 | for line in lines: 73 | consecutive_spaces = 0 74 | for char in line: 75 | if char == " ": 76 | consecutive_spaces += 1 77 | else: 78 | break 79 | 80 | if consecutive_spaces % 2 != 0: 81 | raise AssertionError( 82 | "Indentation was not a multiple of two: {}".format(consecutive_spaces) 83 | ) 84 | 85 | final_lines.append((" " * consecutive_spaces) + line[consecutive_spaces:]) 86 | 87 | return "\n".join(final_lines) 88 | -------------------------------------------------------------------------------- /graphql_compiler/query_formatting/representations.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | """Common representations of various types in Gremlin and MATCH (SQL).""" 3 | import decimal 4 | 5 | from ..exceptions import GraphQLInvalidArgumentError 6 | 7 | 8 | def represent_float_as_str(value): 9 | """Represent a float as a string without losing precision.""" 10 | # In Python 2, calling str() on a float object loses precision: 11 | # 12 | # In [1]: 1.23456789012345678 13 | # Out[1]: 1.2345678901234567 14 | # 15 | # In [2]: 1.2345678901234567 16 | # Out[2]: 1.2345678901234567 17 | # 18 | # In [3]: str(1.2345678901234567) 19 | # Out[3]: '1.23456789012' 20 | # 21 | # The best way to ensure precision is not lost is to convert to string via Decimal: 22 | # https://github.com/mogui/pyorient/pull/226/files 23 | if not isinstance(value, float): 24 | raise GraphQLInvalidArgumentError( 25 | "Attempting to represent a non-float as a float: {}".format(value) 26 | ) 27 | 28 | with decimal.localcontext() as ctx: 29 | ctx.prec = 20 # floats are max 80-bits wide = 20 significant digits 30 | return "{:f}".format(decimal.Decimal(value)) 31 | 32 | 33 | def type_check_and_str(python_type, value): 34 | """Type-check the value, and then just return str(value).""" 35 | if not isinstance(value, python_type): 36 | raise GraphQLInvalidArgumentError( 37 | "Attempting to represent a non-{type} as a {type}: " 38 | "{value}".format(type=python_type, value=value) 39 | ) 40 | 41 | return str(value) 42 | 43 | 44 | def coerce_to_decimal(value): 45 | """Attempt to coerce the value to a Decimal, or raise an error if unable to do so.""" 46 | if isinstance(value, decimal.Decimal): 47 | return value 48 | else: 49 | try: 50 | return decimal.Decimal(value) 51 | except decimal.InvalidOperation as e: 52 | raise GraphQLInvalidArgumentError(e) 53 | -------------------------------------------------------------------------------- /graphql_compiler/query_formatting/sql_formatting.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | from ..compiler.common import SQL_LANGUAGE 3 | 4 | 5 | ###### 6 | # Public API 7 | ###### 8 | 9 | 10 | def insert_arguments_into_sql_query(compilation_result, arguments): 11 | """Insert the arguments into the compiled SQL query to form a complete query. 12 | 13 | Args: 14 | compilation_result: CompilationResult, compilation result from the GraphQL compiler. 15 | arguments: Dict[str, Any], parameter name -> value, for every parameter the query expects. 16 | 17 | Returns: 18 | SQLAlchemy Selectable, a executable SQL query with parameters bound. 19 | """ 20 | if compilation_result.language != SQL_LANGUAGE: 21 | raise AssertionError("Unexpected query output language: {}".format(compilation_result)) 22 | base_query = compilation_result.query 23 | return base_query.params(**arguments) 24 | 25 | 26 | ###### 27 | -------------------------------------------------------------------------------- /graphql_compiler/query_pagination/typedefs.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from dataclasses import dataclass 3 | from typing import Generic, Tuple, TypeVar 4 | 5 | from ..global_utils import ASTWithParameters, QueryStringWithParameters 6 | 7 | 8 | # A representation of a query and its arguments, convenient for a particular use-case. 9 | QueryBundle = TypeVar("QueryBundle", QueryStringWithParameters, ASTWithParameters) 10 | 11 | 12 | @dataclass 13 | class PageAndRemainder(Generic[QueryBundle]): 14 | """The result of pagination.""" 15 | 16 | # A query 17 | whole_query: QueryBundle 18 | 19 | # Desired page size 20 | page_size: int 21 | 22 | # A query containing a single page of results of the whole_query 23 | one_page: QueryBundle 24 | 25 | # A list of queries, that are disjoint with one_page and mutually disjoint, such 26 | # that the union of the one_page and remainder queries describes the whole_query. 27 | # If the whole_query already fits within a page, the remainder is an empty tuple. 28 | # If not, the remainder is nonempty, usually containing one query. The remainder 29 | # contains more than one query when multiple pagination filters are used in the 30 | # query plan. In that case, it is impossible to describe the remainder with a 31 | # single query. 32 | remainder: Tuple[QueryBundle, ...] 33 | -------------------------------------------------------------------------------- /graphql_compiler/query_planning/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/schema/typedefs.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from typing import Dict, Union 3 | 4 | from graphql import ( 5 | GraphQLInterfaceType, 6 | GraphQLList, 7 | GraphQLNonNull, 8 | GraphQLObjectType, 9 | GraphQLScalarType, 10 | GraphQLUnionType, 11 | ) 12 | 13 | 14 | # The valid types that a field inside an object or interface in the GraphQL schema may be. 15 | GraphQLSchemaFieldType = Union[GraphQLList, GraphQLNonNull, GraphQLScalarType] 16 | 17 | # The type of the object that describes which type needs to have which field names forced to 18 | # be a different type than would have been automatically inferred. 19 | # Dict of GraphQL type name -> (Dict of field name on that type -> the desired type of the field) 20 | ClassToFieldTypeOverridesType = Dict[str, Dict[str, GraphQLSchemaFieldType]] 21 | 22 | # The type of the type equivalence hints object, which defines which GraphQL interfaces and object 23 | # types should be considered equivalent to which union types. This is our workaround for the lack 24 | # of interface-interface and object-object inheritance. 25 | TypeEquivalenceHintsType = Dict[Union[GraphQLInterfaceType, GraphQLObjectType], GraphQLUnionType] 26 | -------------------------------------------------------------------------------- /graphql_compiler/schema_generation/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/schema_generation/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | class SchemaError(Exception): 3 | """Base class for all errors related to the schema.""" 4 | 5 | 6 | class InvalidClassError(SchemaError): 7 | """Raised when the requested class did not exist or fulfill certain requirements. 8 | 9 | Possible reasons include: 10 | - Class A was expected to be a subclass of class B, but that was found to not be the case. 11 | - The requested class was expected to be abstract, but it was not. 12 | - The requested class did not exist. 13 | 14 | In each of the cases, the conclusion is the same -- this is a programming error, 15 | and there is nothing the code can do to recover. 16 | """ 17 | 18 | 19 | class InvalidPropertyError(SchemaError): 20 | """Raised when a class was expected to have a given property that did not actually exist.""" 21 | 22 | 23 | class IllegalSchemaStateError(SchemaError): 24 | """Raised when the schema loaded from OrientDB is in an illegal state. 25 | 26 | When loading the OrientDB schema, we check various invariants. For example, 27 | we check that all non-abstract edge classes must define what types of vertices they connect. 28 | These invariants must hold during steady-state operation, but may sometimes be 29 | temporarily violated -- for example, during the process of applying new schema to the database. 30 | This exception is raised in such situations. 31 | 32 | Therefore, if the exception is raised during testing or during steady-state operation 33 | of the graph, it indicates a bug of some sort. If the exception is encountered during 34 | deploys or other activities that may cause schema changes to the database, 35 | it is probably ephemeral and the operation in question may be retried. 36 | """ 37 | 38 | 39 | class EmptySchemaError(SchemaError): 40 | """Raised when there are no visible vertex types to import into the GraphQL schema object.""" 41 | 42 | 43 | class InvalidSQLEdgeError(SchemaError): 44 | """Raised when a SQL edge provided during SQLAlchemySchemaInfo generation is invalid. 45 | 46 | This may be raised if the provided SQL edge refers to a non-existent vertex, or a non-exist 47 | column in a table. In the future, this class might encompass other sort of issues in 48 | specified SQL edges. For instance, it might be raised if an edge implies that we could execute 49 | a SQL join between two columns, but the columns have non-comparable types. 50 | """ 51 | 52 | 53 | class MissingPrimaryKeyError(SchemaError): 54 | """Raised when a SQLAlchemy Table object is missing a primary key. 55 | 56 | The compiler requires that each SQLAlchemy Table object in the SQLALchemySchemaInfo 57 | has a primary key. However, the primary key in the SQLAlchemy Table object need not be the 58 | primary key in the underlying table. It may simply be a non-null and unique identifier of each 59 | row. 60 | """ 61 | -------------------------------------------------------------------------------- /graphql_compiler/schema_generation/orientdb/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from ..graphql_schema import get_graphql_schema_from_schema_graph 3 | from .schema_graph_builder import get_orientdb_schema_graph 4 | 5 | 6 | def get_graphql_schema_from_orientdb_schema_data( 7 | schema_data, class_to_field_type_overrides=None, hidden_classes=None 8 | ): 9 | """Construct a GraphQL schema from an OrientDB schema. 10 | 11 | Args: 12 | schema_data: list of dicts describing the classes in the OrientDB schema. The following 13 | format is the way the data is structured in OrientDB 2. See 14 | the README.md file for an example of how to query this data. 15 | Each dict has the following string fields: 16 | - name: string, the name of the class. 17 | - superClasses (optional): list of strings, the name of the class's 18 | superclasses. 19 | - superClass (optional): string, the name of the class's superclass. May be 20 | used instead of superClasses if there is only one 21 | superClass. Used for backwards compatibility with 22 | OrientDB. 23 | - customFields (optional): dict, string -> string, data defined on the class 24 | instead of instances of the class. 25 | - abstract: bool, true if the class is abstract. 26 | - properties: list of dicts, describing the class's properties. 27 | Each property dictionary has the following string fields: 28 | - name: string, the name of the property. 29 | - type: int, builtin OrientDB type ID of the property. 30 | See schema_properties.py for the mapping. 31 | - linkedType (optional): int, if the property is a 32 | collection of builtin OrientDB 33 | objects, then it indicates their 34 | type ID. 35 | - linkedClass (optional): string, if the property is a 36 | collection of class instances, 37 | then it indicates the name of 38 | the class. If class is an edge 39 | class, and the field name is 40 | either 'in' or 'out', then it 41 | describes the name of an 42 | endpoint of the edge. 43 | - defaultValue: string, the textual representation of the 44 | default value for the property, as 45 | returned by OrientDB's schema 46 | introspection code, e.g., '{}' for 47 | the embedded set type. Note that if the 48 | property is a collection type, it must 49 | have a default value. 50 | class_to_field_type_overrides: optional dict, class name -> {field name -> field type}, 51 | (string -> {string -> GraphQLType}). Used to override the 52 | type of a field in the class where it's first defined and all 53 | the class's subclasses. 54 | hidden_classes: optional set of strings, classes to not include in the GraphQL schema. 55 | 56 | Returns: 57 | tuple of (GraphQL schema object, GraphQL type equivalence hints dict). 58 | The tuple is of type (GraphQLSchema, {GraphQLObjectType -> GraphQLUnionType}). 59 | """ 60 | schema_graph = get_orientdb_schema_graph(schema_data, []) 61 | return get_graphql_schema_from_schema_graph( 62 | schema_graph, 63 | class_to_field_type_overrides=class_to_field_type_overrides, 64 | hidden_classes=hidden_classes, 65 | ) 66 | -------------------------------------------------------------------------------- /graphql_compiler/schema_generation/orientdb/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | 3 | # Match query used to generate OrientDB records that are themselves used to generate GraphQL schema. 4 | ORIENTDB_SCHEMA_RECORDS_QUERY = ( 5 | "SELECT FROM (SELECT expand(classes) FROM metadata:schema) " 6 | "WHERE name NOT IN ['ORole', 'ORestricted', 'OTriggered', " 7 | "'ORIDs', 'OUser', 'OIdentity', 'OSchedule', 'OFunction']" 8 | ) 9 | 10 | ORIENTDB_INDEX_RECORDS_QUERY = ( 11 | "SELECT name, type, indexDefinition, metadata FROM (" 12 | "SELECT expand(indexes) FROM metadata:indexmanager" 13 | ") WHERE type IN " 14 | "['UNIQUE', 'NOTUNIQUE', 'UNIQUE_HASH_INDEX', 'NOTUNIQUE_HASH_INDEX']" 15 | ) 16 | -------------------------------------------------------------------------------- /graphql_compiler/schema_generation/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from ...schema.schema_info import SQLAlchemySchemaInfo 3 | from ..graphql_schema import get_graphql_schema_from_schema_graph 4 | from .edge_descriptors import get_join_descriptors_from_edge_descriptors 5 | from .schema_graph_builder import get_sqlalchemy_schema_graph 6 | 7 | 8 | def get_sqlalchemy_schema_info( 9 | vertex_name_to_table, edges, dialect, class_to_field_type_overrides=None 10 | ): 11 | """Return a SQLAlchemySchemaInfo from the metadata. 12 | 13 | Relational databases are supported by compiling to SQLAlchemy core as an intermediate 14 | language, and then relying on SQLAlchemy's compilation of the dialect-specific SQL string to 15 | query the target database. 16 | 17 | Constructing the SQLAlchemySchemaInfo class, which contains all the info required to compile 18 | SQL queries, requires the use of SQLAlchemy Table objects representing underlying SQL 19 | tables. These can be autogenerated from a SQL database through the reflect() method in a 20 | SQLAlchemy Metadata object. It is also possible to construct the SQLAlchemy Table objects by 21 | using the class's init method and specifying the needed metadata. If you choose to use the 22 | the latter manner, make sure to properly define the optional schema and primary_key fields since 23 | the compiler relies on these to compile GraphQL to SQL. 24 | 25 | Args: 26 | vertex_name_to_table: dict, str -> SQLAlchemy Table. This dictionary is used to generate the 27 | GraphQL objects in the schema in the SQLAlchemySchemaInfo. Each 28 | SQLAlchemyTable will be represented as a GraphQL object. The GraphQL 29 | object names are the dictionary keys. The fields of the GraphQL 30 | objects will be inferred from the columns of the underlying tables. 31 | The fields will have the same name as the underlying columns and 32 | columns with unsupported types, (SQL types with no matching GraphQL 33 | type), will be ignored. 34 | edges: dict, str-> EdgeDescriptor. The traversal of an edge 35 | edge gets compiled to a SQL join in graphql_to_sql(). Therefore, each 36 | EdgeDescriptor not only specifies the source and destination GraphQL 37 | objects, but also which columns to use to use when generating a SQL join 38 | between the underlying source and destination tables. The names of the edges 39 | are the keys in the dictionary and the edges will be rendered as vertex fields 40 | named out_ and in_ in the source and destination GraphQL 41 | objects respectively. The edge names must not conflict with the GraphQL 42 | object names. 43 | dialect: sqlalchemy.engine.interfaces.Dialect, specifying the dialect we are compiling to 44 | (e.g. sqlalchemy.dialects.mssql.dialect()). 45 | class_to_field_type_overrides: optional dict, class name -> {field name -> field type}, 46 | (string -> {string -> GraphQLType}). Used to override the 47 | type of a field in the class where it's first defined and all 48 | the class's subclasses. 49 | 50 | Returns: 51 | SQLAlchemySchemaInfo containing the full information needed to compile SQL queries. 52 | """ 53 | schema_graph = get_sqlalchemy_schema_graph(vertex_name_to_table, edges) 54 | 55 | graphql_schema, type_equivalence_hints = get_graphql_schema_from_schema_graph( 56 | schema_graph, 57 | class_to_field_type_overrides=class_to_field_type_overrides, 58 | hidden_classes=set(), 59 | ) 60 | 61 | join_descriptors = get_join_descriptors_from_edge_descriptors(edges) 62 | 63 | return SQLAlchemySchemaInfo( 64 | graphql_schema, type_equivalence_hints, dialect, vertex_name_to_table, join_descriptors 65 | ) 66 | -------------------------------------------------------------------------------- /graphql_compiler/schema_generation/sqlalchemy/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from typing import Iterable, Set 3 | 4 | from sqlalchemy import Table 5 | 6 | from ..exceptions import MissingPrimaryKeyError 7 | 8 | 9 | def validate_that_tables_have_primary_keys(tables: Iterable[Table]) -> None: 10 | """Validate that each SQLAlchemy Table object has a primary key.""" 11 | tables_missing_primary_keys: Set[str] = set() 12 | for table in tables: 13 | if not table.primary_key: 14 | tables_missing_primary_keys.add(table.fullname) 15 | if tables_missing_primary_keys: 16 | raise MissingPrimaryKeyError( 17 | "At least one SQLAlchemy Table is missing a " 18 | "primary key. Note that the primary keys in SQLAlchemy " 19 | "Table objects do not have to match the primary keys in " 20 | "the underlying row. They must simply be unique and " 21 | f"non-null identifiers of each row. Tables missing primary keys: " 22 | f"{tables_missing_primary_keys}" 23 | ) 24 | 25 | 26 | def validate_that_tables_belong_to_the_same_metadata_object(tables): 27 | """Validate that all the SQLAlchemy Table objects belong to the same MetaData object.""" 28 | metadata = None 29 | for table in tables: 30 | if metadata is None: 31 | metadata = table.metadata 32 | else: 33 | if table.metadata is not metadata: 34 | raise AssertionError( 35 | "Multiple SQLAlchemy MetaData objects used for schema generation." 36 | ) 37 | -------------------------------------------------------------------------------- /graphql_compiler/schema_transformation/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/integration_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/integration_tests/integration_backend_config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | from collections import namedtuple 3 | 4 | from six.moves.urllib.parse import quote_plus 5 | 6 | from .. import test_backend 7 | 8 | 9 | DEFAULT_ROOT_PASSWORD = "root" # nosec 10 | 11 | SQL_BACKENDS = { 12 | test_backend.POSTGRES, 13 | test_backend.MYSQL, 14 | test_backend.MARIADB, 15 | test_backend.MSSQL, 16 | test_backend.SQLITE, 17 | } 18 | 19 | # sqlite does not require that a DB be created/dropped for testing 20 | EXPLICIT_DB_BACKENDS = { 21 | test_backend.POSTGRES, 22 | test_backend.MYSQL, 23 | test_backend.MARIADB, 24 | test_backend.MSSQL, 25 | } 26 | 27 | MATCH_BACKENDS = { 28 | test_backend.ORIENTDB, 29 | } 30 | 31 | # Split Neo4j and RedisGraph because RedisGraph doesn't support all Neo4j features. 32 | NEO4J_BACKENDS = { 33 | test_backend.NEO4J, 34 | } 35 | 36 | REDISGRAPH_BACKENDS = { 37 | test_backend.REDISGRAPH, 38 | } 39 | 40 | pyodbc_parameter_string = "DRIVER={driver};SERVER={server};UID={uid};PWD={pwd}".format( # nosec 41 | driver="{ODBC Driver 17 for SQL SERVER}", 42 | server="127.0.0.1,1434", # Do not change to 'localhost'. 43 | # You won't be able to connect with the db. 44 | uid="SA", # System Administrator. 45 | pwd="Root-secure1", 46 | ) 47 | 48 | # delimiters must be URL escaped 49 | escaped_pyodbc_parameter_string = quote_plus(pyodbc_parameter_string) 50 | 51 | SQL_BACKEND_TO_CONNECTION_STRING = { 52 | # HACK(bojanserafimov): Entries are commented-out because MSSQL is the only one whose scheme 53 | # initialization is properly configured, with a hierarchy of multiple 54 | # databases and schemas. I'm keeping the code to remember the connection 55 | # string formats. 56 | # 57 | test_backend.POSTGRES: "postgresql://postgres:{password}@localhost:5433".format( 58 | password=DEFAULT_ROOT_PASSWORD 59 | ), 60 | # test_backend.MYSQL: 61 | # 'mysql://root:{password}@127.0.0.1:3307'.format(password=DEFAULT_ROOT_PASSWORD), 62 | # test_backend.MARIADB: 63 | # 'mysql://root:{password}@127.0.0.1:3308'.format(password=DEFAULT_ROOT_PASSWORD), 64 | test_backend.MSSQL: "mssql+pyodbc:///?odbc_connect={}".format(escaped_pyodbc_parameter_string), 65 | # test_backend.SQLITE: 66 | # 'sqlite:///:memory:', 67 | } 68 | 69 | SqlTestBackend = namedtuple( 70 | "SqlTestBackend", 71 | ( 72 | "engine", 73 | "base_connection_string", 74 | ), 75 | ) 76 | -------------------------------------------------------------------------------- /graphql_compiler/tests/interpreter_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/interpreter_tests/test_immutable_stack.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020-present Kensho Technologies, LLC. 2 | from copy import copy, deepcopy 3 | from typing import Any, Tuple, cast 4 | import unittest 5 | 6 | from ...interpreter.immutable_stack import ImmutableStack, make_empty_stack 7 | 8 | 9 | class ImmutableStackTests(unittest.TestCase): 10 | def test_equality(self) -> None: 11 | stack_a = make_empty_stack().push(123) 12 | stack_b = make_empty_stack().push(123) 13 | self.assertEqual(stack_a, stack_b) 14 | 15 | self.assertNotEqual(make_empty_stack(), stack_a) 16 | self.assertNotEqual(stack_a.push("foo"), stack_b) 17 | 18 | def test_make_push_and_pop(self) -> None: 19 | initial_stack = make_empty_stack() 20 | self.assertIsNone(initial_stack.value) 21 | self.assertEqual(0, initial_stack.depth) 22 | self.assertIsNone(initial_stack.tail) 23 | 24 | new_stack = initial_stack 25 | values_to_push: Tuple[Any, ...] = (123, "hello world", None) 26 | for index, value_to_push in enumerate(values_to_push): 27 | stack = new_stack 28 | new_stack = stack.push(value_to_push) 29 | 30 | # After the push: 31 | # - the stack depth has increased by one; 32 | self.assertEqual(index + 1, new_stack.depth) 33 | # - the stack's topmost value is referentially equal to the value we just pushed; 34 | self.assertIs(value_to_push, new_stack.value) 35 | # - the stack's tail is referentially equal to the pre-push stack. 36 | self.assertIs(stack, new_stack.tail) 37 | 38 | stack = new_stack 39 | for expected_pop_value in reversed(values_to_push): 40 | actual_pop_value, popped_stack = stack.pop() 41 | 42 | # The popped stack isn't None because it should still have leftover values. 43 | self.assertIsNotNone(popped_stack) 44 | # Cast because mypy doesn't realize the previous line will raise on None. 45 | stack = cast(ImmutableStack, popped_stack) 46 | 47 | # The popped value is referentially equal to the value we originally pushed. 48 | self.assertIs(expected_pop_value, actual_pop_value) 49 | 50 | # At the end of all the pushing and popping, the final stack is empty 51 | # and referentially equal to the initial stack. 52 | self.assertIsNone(stack.value) 53 | self.assertEqual(0, stack.depth) 54 | self.assertIsNone(stack.tail) 55 | self.assertIs(initial_stack, stack) 56 | 57 | def test_stack_copy(self) -> None: 58 | pushed_value = { 59 | 1: "foo", 60 | 2: "bar", 61 | } 62 | stack = make_empty_stack().push(pushed_value) 63 | 64 | # copy() makes a new stack node but keeps all its references the same. 65 | # This is why we check referential equality for the value and the tail. 66 | copied_stack = copy(stack) 67 | self.assertIs(stack.value, copied_stack.value) 68 | self.assertEqual(stack.depth, copied_stack.depth) 69 | self.assertIs(stack.tail, copied_stack.tail) 70 | 71 | # Just a consistency check, if this following fails then something has gone horribly wrong. 72 | self.assertEqual(stack, copied_stack) 73 | 74 | def test_stack_deepcopy(self) -> None: 75 | pushed_value = { 76 | 1: "foo", 77 | 2: "bar", 78 | } 79 | stack = make_empty_stack().push(pushed_value) 80 | 81 | # deepcopy() makes a new stack node and also makes deep copies of all its references. 82 | # This is why we check for lack of referential equality for the value and tail, even though 83 | # we check for equality of the given objects. 84 | copied_stack = deepcopy(stack) 85 | self.assertIsNot(stack.value, copied_stack.value) 86 | self.assertEqual(stack.depth, copied_stack.depth) 87 | self.assertIsNot(stack.tail, copied_stack.tail) 88 | self.assertEqual(stack, copied_stack) 89 | -------------------------------------------------------------------------------- /graphql_compiler/tests/schema_generation_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/schema_transformation_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/schema_transformation_tests/test_make_query_plan.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from textwrap import dedent 3 | import unittest 4 | 5 | from graphql import parse, print_ast 6 | 7 | from ...query_planning.make_query_plan import make_query_plan 8 | from ...schema_transformation.split_query import split_query 9 | from .example_schema import basic_merged_schema 10 | 11 | 12 | class TestMakeQueryPlan(unittest.TestCase): 13 | def test_basic_make_query_plan(self): 14 | query_str = dedent( 15 | """\ 16 | { 17 | Animal { 18 | out_Animal_Creature { 19 | age @output(out_name: "age") 20 | } 21 | } 22 | } 23 | """ 24 | ) 25 | parent_str = dedent( 26 | """\ 27 | { 28 | Animal { 29 | uuid @output(out_name: "__intermediate_output_0") 30 | } 31 | } 32 | """ 33 | ) 34 | child_str_no_filter = dedent( 35 | """\ 36 | { 37 | Creature { 38 | age @output(out_name: "age") 39 | id @output(out_name: "__intermediate_output_1") 40 | } 41 | } 42 | """ 43 | ) 44 | child_str_with_filter = dedent( 45 | """\ 46 | { 47 | Creature { 48 | age @output(out_name: "age") 49 | id @output(out_name: "__intermediate_output_1") \ 50 | @filter(op_name: "in_collection", value: ["$__intermediate_output_0"]) 51 | } 52 | } 53 | """ 54 | ) 55 | query_node, intermediate_outputs = split_query(parse(query_str), basic_merged_schema) 56 | query_plan_descriptor = make_query_plan(query_node, intermediate_outputs) 57 | # Check the child ASTs in the input query node are unchanged (@filter not added)) 58 | child_query_node = query_node.child_query_connections[0].sink_query_node 59 | self.assertEqual(print_ast(child_query_node.query_ast), child_str_no_filter) 60 | # Check the query plan 61 | parent_sub_query_plan = query_plan_descriptor.root_sub_query_plan 62 | self.assertEqual(print_ast(parent_sub_query_plan.query_ast), parent_str) 63 | self.assertEqual(parent_sub_query_plan.schema_id, "first") 64 | self.assertIsNone(parent_sub_query_plan.parent_query_plan) 65 | self.assertEqual(len(parent_sub_query_plan.child_query_plans), 1) 66 | # Check the child query plan 67 | child_sub_query_plan = parent_sub_query_plan.child_query_plans[0] 68 | self.assertEqual(print_ast(child_sub_query_plan.query_ast), child_str_with_filter) 69 | self.assertEqual(child_sub_query_plan.schema_id, "second") 70 | self.assertIs(child_sub_query_plan.parent_query_plan, parent_sub_query_plan) 71 | self.assertEqual(len(child_sub_query_plan.child_query_plans), 0) 72 | # Check the output join descriptors 73 | output_join_descriptors = query_plan_descriptor.output_join_descriptors 74 | self.assertEqual(len(output_join_descriptors), 1) 75 | output_join_descriptor = output_join_descriptors[0] 76 | self.assertEqual( 77 | output_join_descriptor.output_names, 78 | ("__intermediate_output_0", "__intermediate_output_1"), 79 | ) 80 | # Check set of intermediate output names 81 | self.assertEqual( 82 | query_plan_descriptor.intermediate_output_names, 83 | {"__intermediate_output_0", "__intermediate_output_1"}, 84 | ) 85 | -------------------------------------------------------------------------------- /graphql_compiler/tests/snapshot_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/snapshot_tests/snapshots/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_backend.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | 3 | POSTGRES = "postgresql" 4 | MYSQL = "mysql" 5 | MARIADB = "mariadb" 6 | MSSQL = "mssql" 7 | SQLITE = "sqlite" 8 | ORIENTDB = "orientdb" 9 | NEO4J = "neo4j" 10 | REDISGRAPH = "redisgraph" 11 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_data_tools/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_data_tools/neo4j_graph.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from typing import Callable 3 | 4 | from neo4j import GraphDatabase 5 | 6 | 7 | NEO4J_SERVER = "localhost" 8 | NEO4J_PORT = 7688 9 | NEO4J_USER = "neo4j" 10 | NEO4J_PASSWORD = "root" # nosec 11 | 12 | 13 | class Neo4jClient(object): 14 | def __init__(self, graph_name: str) -> None: 15 | """Set up Neo4JClient using the default test credentials.""" 16 | url = get_neo4j_url(graph_name) 17 | self.driver = GraphDatabase.driver(url, auth=(NEO4J_USER, NEO4J_PASSWORD)) 18 | 19 | 20 | def get_neo4j_url(database_name: str) -> str: 21 | """Return an Neo4j path for the specified database on the NEO4J_SERVER.""" 22 | template = "bolt://{}:{}/{}" 23 | return template.format(NEO4J_SERVER, NEO4J_PORT, database_name) 24 | 25 | 26 | def get_test_neo4j_graph( 27 | graph_name: str, generate_data_func: Callable[[Neo4jClient], None] 28 | ) -> Neo4jClient: 29 | """Generate the test database and return the Neo4j client.""" 30 | client = Neo4jClient(graph_name) 31 | generate_data_func(client) 32 | return client 33 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_data_tools/orientdb_graph.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | from typing import Callable 3 | 4 | from pyorient import OrientDB 5 | from pyorient.constants import DB_TYPE_GRAPH 6 | from pyorient.ogm import Config, Graph 7 | 8 | 9 | ORIENTDB_SERVER = "localhost" 10 | ORIENTDB_PORT = 2425 11 | ORIENTDB_USER = "root" 12 | ORIENTDB_PASSWORD = "root" # nosec 13 | 14 | 15 | def get_orientdb_url(database_name: str) -> str: 16 | """Return an OrientDB path for the specified database on the ORIENTDB_SERVER.""" 17 | template = "memory://{}:{}/{}" 18 | return template.format(ORIENTDB_SERVER, ORIENTDB_PORT, database_name) 19 | 20 | 21 | def get_test_orientdb_graph( 22 | graph_name: str, 23 | load_schema_func: Callable[[OrientDB], None], 24 | generate_data_func: Callable[[OrientDB], None], 25 | ) -> OrientDB: 26 | """Generate the test database and return the pyorient client.""" 27 | url = get_orientdb_url(graph_name) 28 | config = Config.from_url(url, ORIENTDB_USER, ORIENTDB_PASSWORD, initial_drop=True) 29 | Graph(config, strict=True) 30 | 31 | client = OrientDB(host="localhost", port=ORIENTDB_PORT) 32 | client.connect(ORIENTDB_USER, ORIENTDB_PASSWORD) 33 | client.db_open(graph_name, ORIENTDB_USER, ORIENTDB_PASSWORD, db_type=DB_TYPE_GRAPH) 34 | 35 | load_schema_func(client) 36 | generate_data_func(client) 37 | 38 | return client 39 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_data_tools/redisgraph_graph.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from typing import Callable 3 | 4 | import redis 5 | from redisgraph import Graph 6 | 7 | 8 | REDISGRAPH_SERVER = "localhost" 9 | REDISGRAPH_PORT = 6380 10 | 11 | 12 | def get_test_redisgraph_graph( 13 | graph_name: str, generate_data_func: Callable[[Graph], None] 14 | ) -> Graph: 15 | """Generate the test database and return the Redisgraph client.""" 16 | # note redis_client is a Redis client, not a Redisgraph client 17 | redis_client = redis.Redis(host=REDISGRAPH_SERVER, port=REDISGRAPH_PORT) 18 | 19 | graph_client = Graph(graph_name, redis_client) # connect to the graph itself 20 | generate_data_func(graph_client) 21 | return graph_client 22 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_data_tools/schema.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | from glob import glob 3 | from os import path 4 | 5 | from pyorient import OrientDB 6 | 7 | 8 | def load_schema(client: OrientDB) -> None: 9 | """Read the schema file and apply the specified SQL updates to the client.""" 10 | project_root = path.dirname(path.dirname(path.abspath(__file__))) 11 | file_path = path.join(project_root, "test_data_tools/schema.sql") 12 | sql_files = glob(file_path) 13 | if len(sql_files) > 1: 14 | raise AssertionError( 15 | "Multiple schema files found. Expected single `schema.sql` " 16 | "in graphql-compiler/graphql_compiler/tests/test_data_tools/" 17 | ) 18 | if len(sql_files) == 0 or sql_files[0] != file_path: 19 | raise AssertionError( 20 | "Schema file not found. Expected graphql-compiler/graphql_compiler/" 21 | "tests/test_data_tools/schema.sql" 22 | ) 23 | 24 | with open(file_path, "r") as update_file: 25 | for line in update_file: 26 | sanitized = line.strip() 27 | if len(sanitized) == 0 or sanitized[0] == "#": 28 | # comment or empty line, ignore 29 | continue 30 | 31 | client.command(sanitized) 32 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_data_tools/schema.sql: -------------------------------------------------------------------------------- 1 | ### UUIDs ### 2 | CREATE CLASS UniquelyIdentifiable ABSTRACT 3 | CREATE PROPERTY UniquelyIdentifiable.uuid String 4 | ALTER PROPERTY UniquelyIdentifiable.uuid MANDATORY TRUE 5 | CREATE INDEX UniquelyIdentifiable.uuid UNIQUE_HASH_INDEX 6 | ############### 7 | 8 | 9 | ### Entity ### 10 | CREATE CLASS Entity EXTENDS V, UniquelyIdentifiable ABSTRACT 11 | CREATE PROPERTY Entity.name String 12 | CREATE INDEX Entity.name UNIQUE 13 | 14 | CREATE PROPERTY Entity.alias EmbeddedSet String 15 | ALTER PROPERTY Entity.alias DEFAULT {} 16 | CREATE INDEX Entity.alias NOTUNIQUE 17 | 18 | CREATE PROPERTY Entity.description String 19 | 20 | CREATE CLASS Entity_Related EXTENDS E 21 | CREATE PROPERTY Entity_Related.in LINK Entity 22 | CREATE PROPERTY Entity_Related.out LINK Entity 23 | CREATE INDEX Entity_Related ON Entity_Related (in, out) UNIQUE_HASH_INDEX 24 | ############### 25 | 26 | 27 | ### Event ### 28 | CREATE CLASS Event EXTENDS Entity 29 | 30 | CREATE PROPERTY Event.event_date DateTime 31 | CREATE INDEX Event.event_date NOTUNIQUE 32 | 33 | CREATE CLASS Event_RelatedEvent EXTENDS E 34 | CREATE PROPERTY Event_RelatedEvent.in LINK Event 35 | CREATE PROPERTY Event_RelatedEvent.out LINK Event 36 | CREATE INDEX Event_RelatedEvent ON Event_RelatedEvent (in, out) UNIQUE_HASH_INDEX 37 | ############### 38 | 39 | 40 | ### BirthEvent ### 41 | CREATE CLASS BirthEvent EXTENDS Event 42 | ############### 43 | 44 | ### FeedingEvent ### 45 | CREATE CLASS FeedingEvent EXTENDS Event 46 | ############### 47 | 48 | ### Location ### 49 | CREATE CLASS Location EXTENDS Entity 50 | ############### 51 | 52 | ### Animal ### 53 | CREATE CLASS Animal EXTENDS Entity 54 | 55 | CREATE PROPERTY Animal.color String 56 | CREATE INDEX Animal.color NOTUNIQUE 57 | 58 | CREATE PROPERTY Animal.birthday Date 59 | CREATE INDEX Animal.birthday NOTUNIQUE 60 | 61 | CREATE PROPERTY Animal.net_worth Decimal 62 | CREATE INDEX Animal.net_worth NOTUNIQUE 63 | 64 | CREATE CLASS Animal_ParentOf EXTENDS E 65 | CREATE PROPERTY Animal_ParentOf.in LINK Animal 66 | CREATE PROPERTY Animal_ParentOf.out LINK Animal 67 | ALTER CLASS Animal_ParentOf CUSTOM human_name_in="Parent" 68 | ALTER CLASS Animal_ParentOf CUSTOM human_name_out="Child" 69 | CREATE INDEX Animal_ParentOf ON Animal_ParentOf (in, out) UNIQUE_HASH_INDEX 70 | 71 | CREATE CLASS Animal_FedAt EXTENDS E 72 | CREATE PROPERTY Animal_FedAt.in LINK FeedingEvent 73 | CREATE PROPERTY Animal_FedAt.out LINK Animal 74 | CREATE INDEX Animal_FedAt ON Animal_FedAt (in, out) UNIQUE_HASH_INDEX 75 | 76 | CREATE CLASS Animal_ImportantEvent EXTENDS E 77 | CREATE PROPERTY Animal_ImportantEvent.in LINK Event 78 | CREATE PROPERTY Animal_ImportantEvent.out LINK Animal 79 | CREATE INDEX Animal_ImportantEvent ON Animal_ImportantEvent (in, out) UNIQUE_HASH_INDEX 80 | 81 | CREATE CLASS Animal_BornAt EXTENDS E 82 | CREATE PROPERTY Animal_BornAt.in LINK BirthEvent 83 | CREATE PROPERTY Animal_BornAt.out LINK Animal 84 | CREATE INDEX Animal_BornAt ON Animal_BornAt (in, out) UNIQUE_HASH_INDEX 85 | 86 | CREATE CLASS Animal_LivesIn EXTENDS E 87 | CREATE PROPERTY Animal_LivesIn.in LINK Location 88 | CREATE PROPERTY Animal_LivesIn.out LINK Animal 89 | CREATE INDEX Animal_LivesIn ON Animal_LivesIn (in, out) UNIQUE_HASH_INDEX 90 | ############### 91 | 92 | 93 | ### FoodOrSpecies ### 94 | CREATE CLASS FoodOrSpecies EXTENDS Entity 95 | ############### 96 | 97 | 98 | ### Species ### 99 | CREATE CLASS Species EXTENDS FoodOrSpecies 100 | 101 | CREATE PROPERTY Species.limbs Integer 102 | CREATE INDEX Species.limbs NOTUNIQUE 103 | 104 | CREATE CLASS Animal_OfSpecies EXTENDS E 105 | CREATE PROPERTY Animal_OfSpecies.in LINK Species 106 | CREATE PROPERTY Animal_OfSpecies.out LINK Animal 107 | CREATE INDEX Animal_OfSpecies ON Animal_OfSpecies (in, out) UNIQUE_HASH_INDEX 108 | 109 | CREATE CLASS Species_Eats EXTENDS E 110 | CREATE PROPERTY Species_Eats.in LINK FoodOrSpecies 111 | CREATE PROPERTY Species_Eats.out LINK Species 112 | CREATE INDEX Species_Eats ON Species_Eats (in, out) UNIQUE_HASH_INDEX 113 | ############### 114 | 115 | 116 | ### Food ### 117 | CREATE CLASS Food EXTENDS FoodOrSpecies 118 | ############### 119 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_fast_introspection.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020-present Kensho Technologies, LLC. 2 | import unittest 3 | 4 | from graphql import GraphQLError, GraphQLSchema, get_introspection_query, graphql_sync 5 | 6 | from ..fast_introspection import ( 7 | _remove_whitespace_from_query, 8 | _whitespace_free_introspection_query, 9 | try_fast_introspection, 10 | ) 11 | from .test_helpers import get_schema 12 | 13 | 14 | introspection_query = get_introspection_query() 15 | 16 | 17 | class FastIntrospectionTests(unittest.TestCase): 18 | def test_graphql_get_introspection_query(self) -> None: 19 | self.assertEqual( 20 | _whitespace_free_introspection_query, 21 | _remove_whitespace_from_query(introspection_query), 22 | ) 23 | 24 | def test_try_fast_introspection_none(self) -> None: 25 | schema = get_schema() 26 | self.assertEqual(try_fast_introspection(schema, "not the right query"), None) 27 | 28 | def test_try_fast_introspection_equal_graphql_sync(self) -> None: 29 | schema = get_schema() 30 | execution_result = try_fast_introspection(schema, introspection_query) 31 | self.assertIsNotNone(execution_result) 32 | if execution_result is not None: 33 | self.assertEqual(graphql_sync(schema, introspection_query), execution_result) 34 | 35 | def test_fast_introspection_validate_schema(self) -> None: 36 | execution_result = try_fast_introspection(GraphQLSchema(), introspection_query) 37 | self.assertIsNotNone(execution_result) 38 | if execution_result is not None: 39 | self.assertIsNone(execution_result.data) 40 | self.assertEqual( 41 | execution_result.errors, [GraphQLError("Query root type must be provided.")] 42 | ) 43 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_global_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020-present Kensho Technologies, LLC. 2 | import unittest 3 | 4 | from ..global_utils import assert_set_equality 5 | 6 | 7 | class GlobalUtilTests(unittest.TestCase): 8 | def test_assert_equality(self) -> None: 9 | # Matching sets 10 | assert_set_equality({"a", "b"}, {"a", "b"}) 11 | 12 | # Additional keys in the first set 13 | with self.assertRaises(AssertionError): 14 | assert_set_equality({"a", "b"}, {"b"}) 15 | 16 | # Additional keys in the second type 17 | with self.assertRaises(AssertionError): 18 | assert_set_equality({"b"}, {"a", "b"}) 19 | 20 | # Different sets with same number of elements 21 | with self.assertRaises(AssertionError): 22 | assert_set_equality({"a", "b"}, {"c", "b"}) 23 | 24 | # Different types 25 | with self.assertRaises(AssertionError): 26 | assert_set_equality({"a"}, {1}) 27 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_graphql_pretty_print.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | from textwrap import dedent 3 | import unittest 4 | 5 | from ..query_formatting.graphql_formatting import pretty_print_graphql 6 | 7 | 8 | class GraphQLPrettyPrintTests(unittest.TestCase): 9 | def test_graphql_pretty_print_indentation(self) -> None: 10 | bad_query = """{ 11 | Animal { 12 | name @output(out_name: "name") 13 | } 14 | }""" 15 | 16 | four_space_output = dedent( 17 | """\ 18 | { 19 | Animal { 20 | name @output(out_name: "name") 21 | } 22 | } 23 | """ 24 | ) 25 | 26 | two_space_output = dedent( 27 | """\ 28 | { 29 | Animal { 30 | name @output(out_name: "name") 31 | } 32 | } 33 | """ 34 | ) 35 | self.assertEqual(four_space_output, pretty_print_graphql(bad_query)) 36 | self.assertEqual(two_space_output, pretty_print_graphql(bad_query, use_four_spaces=False)) 37 | 38 | def test_filter_directive_order(self) -> None: 39 | bad_query = """{ 40 | Animal @filter(value: ["$name"], op_name: "name_or_alias") { 41 | uuid @filter(value: ["$max_uuid"], op_name: "<=") 42 | out_Entity_Related { 43 | ...on Species{ 44 | name @output(out_name: "related_species") 45 | } 46 | } 47 | } 48 | }""" 49 | 50 | expected_output = dedent( 51 | """\ 52 | { 53 | Animal @filter(op_name: "name_or_alias", value: ["$name"]) { 54 | uuid @filter(op_name: "<=", value: ["$max_uuid"]) 55 | out_Entity_Related { 56 | ... on Species { 57 | name @output(out_name: "related_species") 58 | } 59 | } 60 | } 61 | } 62 | """ 63 | ) 64 | 65 | self.assertEqual(expected_output, pretty_print_graphql(bad_query)) 66 | 67 | def test_args_not_in_schema(self) -> None: 68 | bad_query = """{ 69 | Animal @filter(value: ["$name"], unknown_arg: "value", op_name: "name_or_alias") { 70 | uuid @filter(value: ["$max_uuid"], op_name: "<=") 71 | out_Entity_Related { 72 | ...on Species{ 73 | name @output(out_name: "related_species") 74 | } 75 | } 76 | } 77 | }""" 78 | 79 | expected_output = dedent( 80 | """\ 81 | { 82 | Animal @filter(op_name: "name_or_alias", value: ["$name"], unknown_arg: "value") { 83 | uuid @filter(op_name: "<=", value: ["$max_uuid"]) 84 | out_Entity_Related { 85 | ... on Species { 86 | name @output(out_name: "related_species") 87 | } 88 | } 89 | } 90 | } 91 | """ 92 | ) 93 | 94 | self.assertEqual(expected_output, pretty_print_graphql(bad_query)) 95 | 96 | def test_missing_args(self) -> None: 97 | bad_query = """{ 98 | Animal @filter(value: ["$name"]) { 99 | uuid @filter(value: ["$max_uuid"], op_name: "<=") 100 | 101 | out_Entity_Related { 102 | ...on Species{ 103 | name @output(out_name: "related_species") 104 | } 105 | } 106 | } 107 | }""" 108 | 109 | expected_output = dedent( 110 | """\ 111 | { 112 | Animal @filter(value: ["$name"]) { 113 | uuid @filter(op_name: "<=", value: ["$max_uuid"]) 114 | out_Entity_Related { 115 | ... on Species { 116 | name @output(out_name: "related_species") 117 | } 118 | } 119 | } 120 | } 121 | """ 122 | ) 123 | 124 | self.assertEqual(expected_output, pretty_print_graphql(bad_query)) 125 | 126 | def test_other_directive(self) -> None: 127 | bad_query = """{ 128 | Animal @filter(value: ["$name"]) { 129 | uuid @filter(value: ["$max_uuid"], op_name: "<=") 130 | 131 | out_Entity_Related @other(arg1: "val1", arg2: "val2") { 132 | ...on Species{ 133 | name @output(out_name: "related_species") 134 | } 135 | } 136 | } 137 | }""" 138 | 139 | expected_output = dedent( 140 | """\ 141 | { 142 | Animal @filter(value: ["$name"]) { 143 | uuid @filter(op_name: "<=", value: ["$max_uuid"]) 144 | out_Entity_Related @other(arg1: "val1", arg2: "val2") { 145 | ... on Species { 146 | name @output(out_name: "related_species") 147 | } 148 | } 149 | } 150 | } 151 | """ 152 | ) 153 | 154 | self.assertEqual(expected_output, pretty_print_graphql(bad_query)) 155 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_macro_expansion_errors.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | from typing import Any, Dict 3 | import unittest 4 | 5 | from ..exceptions import GraphQLCompilationError, GraphQLValidationError 6 | from ..macros import get_schema_with_macros, perform_macro_expansion 7 | from .test_helpers import get_test_macro_registry 8 | 9 | 10 | class MacroExpansionTests(unittest.TestCase): 11 | def setUp(self) -> None: 12 | """Disable max diff limits for all tests.""" 13 | self.maxDiff = None 14 | self.macro_registry = get_test_macro_registry() 15 | self.schema_with_macros = get_schema_with_macros(self.macro_registry) 16 | 17 | def test_macro_edge_duplicate_edge_traversal(self) -> None: 18 | query = """{ 19 | Animal { 20 | out_Animal_BornAt { 21 | name @output(out_name: "name") 22 | } 23 | out_Animal_RichYoungerSiblings { 24 | uuid 25 | } 26 | } 27 | }""" 28 | args: Dict[str, Any] = {} 29 | 30 | with self.assertRaises(GraphQLCompilationError): 31 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 32 | 33 | def test_macro_edge_duplicate_macro_traversal(self) -> None: 34 | query = """{ 35 | Animal { 36 | out_Animal_RichYoungerSiblings { 37 | name @output(out_name: "name") 38 | } 39 | out_Animal_RichYoungerSiblings { 40 | uuid 41 | } 42 | } 43 | }""" 44 | args: Dict[str, Any] = {} 45 | 46 | with self.assertRaises(GraphQLCompilationError): 47 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 48 | 49 | def test_macro_edge_target_coercion_invalid_1(self) -> None: 50 | query = """{ 51 | Animal { 52 | out_Animal_RelatedFood { 53 | ... on Species { 54 | name @output(out_name: "species") 55 | } 56 | } 57 | } 58 | }""" 59 | args: Dict[str, Any] = {} 60 | 61 | with self.assertRaises(GraphQLValidationError): 62 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 63 | 64 | def test_macro_edge_invalid_coercion_2(self) -> None: 65 | query = """{ 66 | Animal { 67 | out_Animal_RelatedEvent { 68 | ... on Entity { 69 | name @output(out_name: "event") 70 | } 71 | } 72 | } 73 | }""" 74 | args: Dict[str, Any] = {} 75 | 76 | with self.assertRaises(GraphQLValidationError): 77 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 78 | 79 | def test_macro_edge_nonexistent(self) -> None: 80 | query = """{ 81 | Animal { 82 | out_Garbage_ThisMacroIsNotInTheRegistry { 83 | name @output(out_name: "grandkid") 84 | } 85 | } 86 | }""" 87 | args: Dict[str, Any] = {} 88 | 89 | with self.assertRaises(GraphQLValidationError): 90 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 91 | 92 | def test_incorrect_schema_usage(self) -> None: 93 | # Test with fields that don't exist in the schema 94 | query = """{ 95 | Animal { 96 | out_Animal_GrandparentOf { 97 | field_not_in_schema @output(out_name: "grandkid") 98 | } 99 | } 100 | }""" 101 | args: Dict[str, Any] = {} 102 | 103 | with self.assertRaises(GraphQLValidationError): 104 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 105 | 106 | def test_recurse_at_expansion_is_not_supported(self) -> None: 107 | query = """{ 108 | Animal { 109 | out_Animal_GrandparentOf @recurse(depth: 3) { 110 | name @output(out_name: "grandkid") 111 | } 112 | } 113 | }""" 114 | args: Dict[str, Any] = {} 115 | 116 | with self.assertRaises(GraphQLCompilationError): 117 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 118 | 119 | def test_optional_at_expansion_is_not_supported(self) -> None: 120 | query = """{ 121 | Animal { 122 | out_Animal_GrandparentOf @optional { 123 | name @output(out_name: "grandkid") 124 | } 125 | } 126 | }""" 127 | args: Dict[str, Any] = {} 128 | 129 | with self.assertRaises(GraphQLCompilationError): 130 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 131 | 132 | def test_fold_at_expansion_is_not_supported(self) -> None: 133 | query = """{ 134 | Animal { 135 | name @output(out_name: "name") 136 | out_Animal_GrandparentOf @fold { 137 | name @output(out_name: "grandkid") 138 | } 139 | } 140 | }""" 141 | args: Dict[str, Any] = {} 142 | 143 | with self.assertRaises(GraphQLCompilationError): 144 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 145 | 146 | def test_output_source_at_expansion_is_not_supported(self) -> None: 147 | query = """{ 148 | Animal { 149 | name @output(out_name: "name") 150 | out_Animal_GrandparentOf @output_source { 151 | name @output(out_name: "grandkid") 152 | } 153 | } 154 | }""" 155 | args: Dict[str, Any] = {} 156 | 157 | with self.assertRaises(GraphQLCompilationError): 158 | perform_macro_expansion(self.macro_registry, self.schema_with_macros, query, args) 159 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_macro_schema.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | import unittest 3 | 4 | from graphql.type import GraphQLList 5 | from graphql.utilities import print_schema 6 | from graphql.validation import validate 7 | 8 | from ..ast_manipulation import safe_parse_graphql 9 | from ..macros import get_schema_for_macro_definition, get_schema_with_macros 10 | from ..macros.macro_edge.directives import ( 11 | DIRECTIVES_ALLOWED_IN_MACRO_EDGE_DEFINITION, 12 | DIRECTIVES_REQUIRED_IN_MACRO_EDGE_DEFINITION, 13 | ) 14 | from ..schema import OutputDirective, OutputSourceDirective 15 | from .test_helpers import VALID_MACROS_TEXT, get_empty_test_macro_registry, get_test_macro_registry 16 | 17 | 18 | class MacroSchemaTests(unittest.TestCase): 19 | def setUp(self) -> None: 20 | """Disable max diff limits for all tests.""" 21 | self.maxDiff = None 22 | self.macro_registry = get_test_macro_registry() 23 | 24 | def test_get_schema_with_macros_original_schema_unchanged(self) -> None: 25 | empty_macro_registry = get_empty_test_macro_registry() 26 | original_printed_schema = print_schema(self.macro_registry.schema_without_macros) 27 | printed_schema_with_0_macros = print_schema(get_schema_with_macros(empty_macro_registry)) 28 | printed_schema_afterwards = print_schema(self.macro_registry.schema_without_macros) 29 | self.assertEqual(original_printed_schema, printed_schema_afterwards) 30 | self.assertEqual(original_printed_schema, printed_schema_with_0_macros) 31 | 32 | def test_get_schema_with_macros_basic(self) -> None: 33 | schema_with_macros = get_schema_with_macros(self.macro_registry) 34 | grandparent_target_type = ( 35 | schema_with_macros.get_type("Animal").fields["out_Animal_GrandparentOf"].type 36 | ) 37 | self.assertTrue(isinstance(grandparent_target_type, GraphQLList)) 38 | self.assertEqual("Animal", grandparent_target_type.of_type.name) 39 | related_food_target_type = ( 40 | schema_with_macros.get_type("Animal").fields["out_Animal_RelatedFood"].type 41 | ) 42 | self.assertTrue(isinstance(related_food_target_type, GraphQLList)) 43 | self.assertEqual("Food", related_food_target_type.of_type.name) 44 | 45 | def test_get_schema_for_macro_definition_addition(self) -> None: 46 | original_schema = self.macro_registry.schema_without_macros 47 | macro_definition_schema = get_schema_for_macro_definition(original_schema) 48 | macro_schema_directive_names = { 49 | directive.name for directive in macro_definition_schema.directives 50 | } 51 | for directive in DIRECTIVES_REQUIRED_IN_MACRO_EDGE_DEFINITION: 52 | self.assertIn(directive, macro_schema_directive_names) 53 | 54 | def test_get_schema_for_macro_definition_retain(self) -> None: 55 | original_schema = self.macro_registry.schema_without_macros 56 | macro_definition_schema = get_schema_for_macro_definition(original_schema) 57 | macro_schema_directive_names = { 58 | directive.name for directive in macro_definition_schema.directives 59 | } 60 | for directive in original_schema.directives: 61 | if directive.name in DIRECTIVES_ALLOWED_IN_MACRO_EDGE_DEFINITION: 62 | self.assertIn(directive.name, macro_schema_directive_names) 63 | 64 | def test_get_schema_for_macro_definition_removal(self) -> None: 65 | schema_with_macros = get_schema_with_macros(self.macro_registry) 66 | macro_definition_schema = get_schema_for_macro_definition(schema_with_macros) 67 | for directive in macro_definition_schema.directives: 68 | self.assertTrue(directive.name != OutputDirective.name) 69 | self.assertTrue(directive.name != OutputSourceDirective.name) 70 | 71 | def test_get_schema_for_macro_definition_validation(self) -> None: 72 | macro_definition_schema = get_schema_for_macro_definition( 73 | self.macro_registry.schema_without_macros 74 | ) 75 | 76 | for macro, _ in VALID_MACROS_TEXT: 77 | macro_edge_definition_ast = safe_parse_graphql(macro) 78 | validate(macro_definition_schema, macro_edge_definition_ast) 79 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_sqlalchemy_extensions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | import unittest 3 | 4 | import sqlalchemy 5 | import sqlalchemy.dialects.mssql as mssql 6 | import sqlalchemy.dialects.postgresql as postgresql 7 | 8 | from ..compiler.sqlalchemy_extensions import print_sqlalchemy_query_string 9 | from .test_helpers import compare_sql, get_sqlalchemy_schema_info 10 | 11 | 12 | class CommonIrLoweringTests(unittest.TestCase): 13 | def setUp(self): 14 | """Disable max diff limits for all tests.""" 15 | self.maxDiff = None 16 | self.sql_schema_info = get_sqlalchemy_schema_info() 17 | 18 | def test_print_query_mssql_basic(self) -> None: 19 | query = sqlalchemy.select([self.sql_schema_info.vertex_name_to_table["Animal"].c.name]) 20 | 21 | text = print_sqlalchemy_query_string(query, mssql.dialect()) 22 | expected_text = """ 23 | SELECT db_1.schema_1.[Animal].name 24 | FROM db_1.schema_1.[Animal] 25 | """ 26 | compare_sql(self, expected_text, text) 27 | 28 | text = print_sqlalchemy_query_string(query, postgresql.dialect()) 29 | expected_text = """ 30 | SELECT "db_1.schema_1"."Animal".name 31 | FROM "db_1.schema_1"."Animal" 32 | """ 33 | compare_sql(self, expected_text, text) 34 | 35 | def test_print_query_mssql_string_argument(self) -> None: 36 | animal = self.sql_schema_info.vertex_name_to_table["Animal"].alias() 37 | query = sqlalchemy.select([animal.c.name]).where( 38 | animal.c.name == sqlalchemy.bindparam("name", expanding=False) 39 | ) 40 | 41 | text = print_sqlalchemy_query_string(query, mssql.dialect()) 42 | expected_text = """ 43 | SELECT [Animal_1].name 44 | FROM db_1.schema_1.[Animal] AS [Animal_1] 45 | WHERE [Animal_1].name = :name 46 | """ 47 | compare_sql(self, expected_text, text) 48 | 49 | text = print_sqlalchemy_query_string(query, postgresql.dialect()) 50 | expected_text = """ 51 | SELECT "Animal_1".name 52 | FROM "db_1.schema_1"."Animal" AS "Animal_1" 53 | WHERE "Animal_1".name = :name 54 | """ 55 | compare_sql(self, expected_text, text) 56 | 57 | def test_print_query_mssql_list_argument(self) -> None: 58 | animal = self.sql_schema_info.vertex_name_to_table["Animal"].alias() 59 | query = sqlalchemy.select([animal.c.name]).where( 60 | animal.c.name.in_(sqlalchemy.bindparam("names", expanding=True)) 61 | ) 62 | 63 | text = print_sqlalchemy_query_string(query, mssql.dialect()) 64 | expected_text = """ 65 | SELECT [Animal_1].name 66 | FROM db_1.schema_1.[Animal] AS [Animal_1] 67 | WHERE [Animal_1].name IN :names 68 | """ 69 | compare_sql(self, expected_text, text) 70 | 71 | text = print_sqlalchemy_query_string(query, postgresql.dialect()) 72 | expected_text = """ 73 | SELECT "Animal_1".name 74 | FROM "db_1.schema_1"."Animal" AS "Animal_1" 75 | WHERE "Animal_1".name IN :names 76 | """ 77 | compare_sql(self, expected_text, text) 78 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_subclass.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | import unittest 3 | 4 | from ..compiler.subclass import compute_subclass_sets 5 | from .test_helpers import get_schema 6 | 7 | 8 | class SubclassTests(unittest.TestCase): 9 | """Ensure we correctly compute subclass sets.""" 10 | 11 | def setUp(self): 12 | """Initialize the test schema once for all tests.""" 13 | self.schema = get_schema() 14 | 15 | def test_compute_subclass_sets(self) -> None: 16 | type_equivalence_hints = { 17 | self.schema.get_type("Event"): self.schema.get_type( 18 | "Union__BirthEvent__Event__FeedingEvent" 19 | ), 20 | } 21 | 22 | subclass_sets = compute_subclass_sets( 23 | self.schema, type_equivalence_hints=type_equivalence_hints 24 | ) 25 | cases = [ 26 | ("Entity", "Entity", True), 27 | ("Animal", "Animal", True), 28 | ("Animal", "Entity", True), 29 | ("Entity", "Animal", False), 30 | ("Species", "Entity", True), 31 | ("BirthEvent", "Event", True), # Derived from the type_equivalence_hints 32 | ] 33 | for cls1, cls2, expected in cases: 34 | is_subclass = cls1 in subclass_sets[cls2] 35 | self.assertEqual( 36 | expected, 37 | is_subclass, 38 | "{} is subclass of {} evaluates to {}. Expected: {}".format( 39 | cls1, cls2, is_subclass, expected 40 | ), 41 | ) 42 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_test_data.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-present Kensho Technologies, LLC. 2 | import unittest 3 | 4 | from .test_helpers import get_sqlalchemy_schema_info 5 | 6 | 7 | class QueryFormattingTests(unittest.TestCase): 8 | def test_sqlalchemy_schema_info(self) -> None: 9 | # Test that validation passes 10 | get_sqlalchemy_schema_info() 11 | -------------------------------------------------------------------------------- /graphql_compiler/tests/test_testing_invariants.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | """Common GraphQL test inputs and expected outputs.""" 3 | import unittest 4 | 5 | from . import test_input_data as test_input_data 6 | from .test_helpers import get_function_names_from_module, get_test_function_names_from_class 7 | 8 | 9 | # The namedtuple function is imported from test_input_data, 10 | # but does not correspond to any test inputs. 11 | IGNORED_FUNCTIONS = frozenset({"namedtuple"}) 12 | 13 | 14 | class TestingInvariants(unittest.TestCase): 15 | def setUp(self): 16 | self.maxDiff = None 17 | input_names = get_function_names_from_module(test_input_data) 18 | self.expected_test_functions = { 19 | "test_" + input_name 20 | for input_name in input_names 21 | if input_name not in IGNORED_FUNCTIONS 22 | } 23 | 24 | def test_ir_generation_test_invariants(self) -> None: 25 | # Importing IrGenerationTests globally would expose them to py.test a second time. 26 | # We import them here so that these tests are not run again. 27 | from .test_ir_generation import IrGenerationTests 28 | 29 | ir_generation_test_names = get_test_function_names_from_class(IrGenerationTests) 30 | for expected_test_function_name in self.expected_test_functions: 31 | if expected_test_function_name not in ir_generation_test_names: 32 | raise AssertionError( 33 | 'Test case "{}" not found in test_ir_generation.py.'.format( 34 | expected_test_function_name 35 | ) 36 | ) 37 | 38 | def test_compiler_test_invariants(self) -> None: 39 | # Importing CompilerTests globally would expose them to py.test a second time. 40 | # We import them here so that these tests are not run again. 41 | from .test_compiler import CompilerTests 42 | 43 | compiler_test_names = get_test_function_names_from_class(CompilerTests) 44 | for expected_test_function_name in self.expected_test_functions: 45 | if expected_test_function_name not in compiler_test_names: 46 | raise AssertionError( 47 | 'Test case "{}" not found in test_compiler.py.'.format( 48 | expected_test_function_name 49 | ) 50 | ) 51 | -------------------------------------------------------------------------------- /graphql_compiler/tool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2017-present Kensho Technologies, LLC. 3 | """Utility modeled after json.tool, pretty-prints GraphQL read from stdin and outputs to stdout. 4 | 5 | Used as: python -m graphql_compiler.tool 6 | """ 7 | import sys 8 | 9 | from . import pretty_print_graphql 10 | 11 | 12 | def main() -> None: 13 | """Read a GraphQL query from standard input, and output it pretty-printed to standard output.""" 14 | query = " ".join(sys.stdin.readlines()) 15 | 16 | sys.stdout.write(pretty_print_graphql(query)) 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /graphql_compiler/typedefs.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020-present Kensho Technologies, LLC. 2 | import sys 3 | from typing import Union 4 | 5 | from graphql import GraphQLList, GraphQLNonNull, GraphQLScalarType 6 | from graphql.language.ast import ( 7 | BooleanValueNode, 8 | EnumValueNode, 9 | FloatValueNode, 10 | IntValueNode, 11 | StringValueNode, 12 | ) 13 | 14 | 15 | # The below code is an import shim for libraries added in Python 3.8: we don't want to conditionally 16 | # import them from every file that needs them. Instead, we conditionally import them here and then 17 | # import from this file in every other location where they are needed. 18 | # 19 | # We prefer the explicit sys.version_info check instead of the more common try-except ImportError 20 | # approach, because at the moment mypy seems to have an easier time with the sys.version_info check: 21 | # https://github.com/python/mypy/issues/1393 22 | # 23 | # Hence, the "unused import" warnings here are false-positives. 24 | if sys.version_info[:2] >= (3, 8): 25 | # These were only added to typing in Python 3.8 26 | from typing import Literal, TypedDict, Protocol # noqa # pylint: disable=unused-import 27 | else: 28 | from typing_extensions import ( # noqa # pylint: disable=unused-import 29 | Literal, 30 | TypedDict, 31 | Protocol, 32 | ) 33 | 34 | 35 | # The compiler's supported GraphQL types for query arguments. The GraphQL type of a query argument 36 | # is the type of the field that the argument references. Not to be confused with the GraphQLArgument 37 | # class in the GraphQL core library. 38 | QueryArgumentGraphQLType = Union[ 39 | GraphQLScalarType, 40 | GraphQLList[GraphQLScalarType], 41 | GraphQLList[GraphQLNonNull[GraphQLScalarType]], 42 | GraphQLNonNull[GraphQLScalarType], 43 | GraphQLNonNull[GraphQLList[GraphQLScalarType]], 44 | GraphQLNonNull[GraphQLList[GraphQLNonNull[GraphQLScalarType]]], 45 | ] 46 | 47 | ScalarConstantValueNodes = ( 48 | BooleanValueNode, 49 | EnumValueNode, 50 | FloatValueNode, 51 | IntValueNode, 52 | StringValueNode, 53 | ) 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | exclude = "graphql_compiler/tests/snapshot_tests/snapshots/" 4 | -------------------------------------------------------------------------------- /scripts/copyright_line_check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2018-present Kensho Technologies, LLC. 3 | 4 | # Fail on first error, on undefined variables, and on errors in a pipeline. 5 | set -euo pipefail 6 | 7 | # Ensure that the "**" glob operator is applied recursively. 8 | # Make globs that do not match return null values. 9 | shopt -s globstar nullglob 10 | 11 | # Make sure the current working directory for this script is the root directory. 12 | cd "$(git -C "$(dirname "${0}")" rev-parse --show-toplevel )" 13 | 14 | ensure_file_has_copyright_line() { 15 | filename="$1" 16 | 17 | lines_to_examine=2 18 | copyright_regex='# Copyright 2[0-9][0-9][0-9]\-present Kensho Technologies, LLC\.' 19 | 20 | file_head=$(head -"$lines_to_examine" "$filename") 21 | set +e 22 | echo "$file_head" | grep --regexp="$copyright_regex" >/dev/null 23 | result="$?" 24 | set -e 25 | 26 | if [[ "$result" != "0" ]]; then 27 | # The check will have to be more sophisticated if we 28 | echo "The file $filename appears to be missing a copyright line, file starts:" 29 | echo "$file_head" 30 | echo 'Please add the following at the top of the file (right after the #! line in scripts):' 31 | echo -e "\n # Copyright $(date +%Y)-present Kensho Technologies, LLC.\n" 32 | exit 1 33 | fi 34 | } 35 | 36 | # There may be many Python files in the root directory that are not checked into the repo: 37 | # virtualenv files, package build files etc. 38 | # Only check the setup.py file in the root directory. 39 | ensure_file_has_copyright_line './setup.py' 40 | 41 | # Check every python file in the package's source directory. 42 | for filename in ./graphql_compiler/**/*.py; do 43 | # Don't run copyright check for snapshot files 44 | if [[ "$filename" != *"/snapshots/snap_"*".py" ]]; then 45 | ensure_file_has_copyright_line "$filename" 46 | fi 47 | done 48 | -------------------------------------------------------------------------------- /scripts/fix_lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2020-present Kensho Technologies, LLC. 3 | 4 | # Assert script is running inside pipenv shell 5 | if [[ "$VIRTUAL_ENV" == "" ]] 6 | then 7 | echo "Please run pipenv shell first" 8 | exit 1 9 | fi 10 | 11 | # Exit non-zero on errors, undefined variables, and errors in pipelines. 12 | set -euo pipefail 13 | 14 | # Ensure that the "**" glob operator is applied recursively. 15 | # Make globs that do not match return null values. 16 | shopt -s globstar nullglob 17 | 18 | # Make sure the current working directory for this script is the root directory. 19 | cd "$(git -C "$(dirname "${0}")" rev-parse --show-toplevel )" 20 | 21 | # Print each command 22 | set -x 23 | 24 | black . 25 | isort --recursive . 26 | -------------------------------------------------------------------------------- /scripts/generate_test_sql/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | import codecs 3 | import datetime 4 | from os import path 5 | import random 6 | import re 7 | import sys 8 | 9 | from .animals import get_animal_generation_commands 10 | from .events import get_event_generation_commands 11 | from .species import get_species_generation_commands 12 | 13 | 14 | # https://packaging.python.org/guides/single-sourcing-package-version/ 15 | # #single-sourcing-the-version 16 | 17 | 18 | def read_file(filename): 19 | """Read and return text from the file specified by `filename`, in the project root directory.""" 20 | # intentionally *not* adding an encoding option to open 21 | # see here: 22 | # https://github.com/pypa/virtualenv/issues/201#issuecomment-3145690 23 | top_level_directory = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) 24 | with codecs.open(path.join(top_level_directory, "graphql_compiler", filename), "r") as f: 25 | return f.read() 26 | 27 | 28 | def find_version(): 29 | """Return current version of package.""" 30 | version_file = read_file("__init__.py") 31 | version_match = re.search(r'^__version__ = ["\']([^"\']*)["\']', version_file, re.M) 32 | if version_match: 33 | return version_match.group(1) 34 | raise RuntimeError("Unable to find version string.") 35 | 36 | 37 | def main(): 38 | """Print a list of SQL commands to generate the testing database.""" 39 | random.seed(0) 40 | 41 | module_path = path.relpath(__file__) 42 | current_datetime = datetime.datetime.now().isoformat() 43 | 44 | log_message = ( 45 | "# Auto-generated output from `{path}`.\n" 46 | "# Do not edit directly!\n" 47 | "# Generated on {datetime} from compiler version {version}.\n\n" 48 | ) 49 | 50 | sys.stdout.write( 51 | log_message.format(path=module_path, datetime=current_datetime, version=find_version()) 52 | ) 53 | 54 | sql_command_generators = [ 55 | get_event_generation_commands, 56 | get_species_generation_commands, 57 | get_animal_generation_commands, 58 | ] 59 | for sql_command_generator in sql_command_generators: 60 | sql_command_list = sql_command_generator() 61 | sys.stdout.write("\n".join(sql_command_list)) 62 | sys.stdout.write("\n") 63 | 64 | 65 | if __name__ == "__main__": 66 | main() 67 | -------------------------------------------------------------------------------- /scripts/generate_test_sql/events.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | from .utils import create_vertex_statement, get_random_date, get_uuid 3 | 4 | 5 | FEEDING_EVENT_NAMES_LIST = ( 6 | "Breakfast", 7 | "Brunch", 8 | "Lunch", 9 | "Dinner", 10 | ) 11 | 12 | 13 | def _create_feeding_event_statement(event_name): 14 | """Return a SQL statement to create a FeedingEvent vertex.""" 15 | field_name_to_value = {"name": event_name, "event_date": get_random_date(), "uuid": get_uuid()} 16 | return create_vertex_statement("FeedingEvent", field_name_to_value) 17 | 18 | 19 | def get_event_generation_commands(): 20 | """Return a list of SQL statements to create all event vertices.""" 21 | command_list = [] 22 | 23 | for event_name in FEEDING_EVENT_NAMES_LIST: 24 | command_list.append(_create_feeding_event_statement(event_name)) 25 | 26 | return command_list 27 | -------------------------------------------------------------------------------- /scripts/generate_test_sql/species.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | import random 3 | 4 | from .utils import create_edge_statement, create_vertex_statement, get_random_limbs, get_uuid 5 | 6 | 7 | SPECIES_LIST = ( 8 | "Nazgul", 9 | "Pteranodon", 10 | "Dragon", 11 | "Hippogriff", 12 | ) 13 | FOOD_LIST = ( 14 | "Bacon", 15 | "Lembas", 16 | "Blood pie", 17 | ) 18 | NUM_FOODS = 2 19 | 20 | 21 | def _create_food_statement(food_name): 22 | """Return a SQL statement to create a Food vertex.""" 23 | field_name_to_value = {"name": food_name, "uuid": get_uuid()} 24 | return create_vertex_statement("Food", field_name_to_value) 25 | 26 | 27 | def _create_species_statement(species_name): 28 | """Return a SQL statement to create a Species vertex.""" 29 | field_name_to_value = {"name": species_name, "limbs": get_random_limbs(), "uuid": get_uuid()} 30 | return create_vertex_statement("Species", field_name_to_value) 31 | 32 | 33 | def _create_species_eats_statement(from_name, to_name): 34 | """Return a SQL statement to create a Species_Eats edge.""" 35 | if to_name in SPECIES_LIST: 36 | to_class = "Species" 37 | elif to_name in FOOD_LIST: 38 | to_class = "Food" 39 | else: 40 | raise AssertionError("Invalid name for Species_Eats endpoint: {}".format(to_name)) 41 | return create_edge_statement("Species_Eats", "Species", from_name, to_class, to_name) 42 | 43 | 44 | def get_species_generation_commands(): 45 | """Return a list of SQL statements to create all species vertices.""" 46 | command_list = [] 47 | 48 | for food_name in FOOD_LIST: 49 | command_list.append(_create_food_statement(food_name)) 50 | for species_name in SPECIES_LIST: 51 | command_list.append(_create_species_statement(species_name)) 52 | 53 | for species_name in SPECIES_LIST: 54 | for food_or_species_name in random.sample(SPECIES_LIST + FOOD_LIST, NUM_FOODS): # nosec 55 | command_list.append(_create_species_eats_statement(species_name, food_or_species_name)) 56 | 57 | return command_list 58 | -------------------------------------------------------------------------------- /scripts/generate_test_sql/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present Kensho Technologies, LLC. 2 | import datetime 3 | from decimal import Decimal 4 | import random 5 | from uuid import UUID 6 | 7 | import six 8 | 9 | 10 | CREATE_VERTEX = "create vertex " 11 | CREATE_EDGE = "create edge " 12 | SEPARATOR = "__" 13 | 14 | 15 | def get_uuid(): 16 | """Return a pseudorandom uuid.""" 17 | return str(UUID(int=random.randint(0, 2 ** 128 - 1))) # nosec 18 | 19 | 20 | def get_random_net_worth(): 21 | """Return a pseudorandom net worth.""" 22 | return Decimal(int(1e5 * random.random()) / 100.0) # nosec 23 | 24 | 25 | def get_random_limbs(): 26 | """Return a pseudorandom number of limbs.""" 27 | return random.randint(2, 10) # nosec 28 | 29 | 30 | def get_random_date(): 31 | """Return a pseudorandom date.""" 32 | random_year = random.randint(2000, 2018) # nosec 33 | random_month = random.randint(1, 12) # nosec 34 | random_day = random.randint(1, 28) # nosec 35 | return datetime.date(random_year, random_month, random_day) 36 | 37 | 38 | def select_vertex_statement(vertex_type, name): 39 | """Return a SQL statement to select a vertex of given type by its `name` field.""" 40 | template = "(select from {vertex_type} where name = '{name}')" 41 | args = {"vertex_type": vertex_type, "name": name} 42 | return template.format(**args) 43 | 44 | 45 | def set_statement(field_name, field_value): 46 | """Return a SQL clause (used in creating a vertex) to set a field to a value.""" 47 | if not isinstance(field_name, six.string_types): 48 | raise AssertionError("Expected string field_name. Received {}".format(field_name)) 49 | field_value_representation = repr(field_value) 50 | if isinstance(field_value, datetime.date): 51 | field_value_representation = 'DATE("' + field_value.isoformat() + ' 00:00:00")' 52 | template = "{} = {}" 53 | return template.format(field_name, field_value_representation) 54 | 55 | 56 | def create_vertex_statement(vertex_type, field_name_to_value): 57 | """Return a SQL statement to create a vertex.""" 58 | statement = CREATE_VERTEX + vertex_type 59 | set_field_clauses = [ 60 | set_statement(field_name, field_name_to_value[field_name]) 61 | for field_name in sorted(six.iterkeys(field_name_to_value)) 62 | ] 63 | statement += " set " + ", ".join(set_field_clauses) 64 | return statement 65 | 66 | 67 | def create_edge_statement(edge_name, from_class, from_name, to_class, to_name): 68 | """Return a SQL statement to create a edge.""" 69 | statement = CREATE_EDGE + edge_name + " from {} to {}" 70 | from_select = select_vertex_statement(from_class, from_name) 71 | to_select = select_vertex_statement(to_class, to_name) 72 | return statement.format(from_select, to_select) 73 | 74 | 75 | def create_name(base_name, label): 76 | """Return a name formed by joining a base name with a label.""" 77 | return base_name + SEPARATOR + label 78 | 79 | 80 | def extract_base_name_and_label(name): 81 | """Extract and return a pair of (base_name, label) from a given name field.""" 82 | if not isinstance(name, six.string_types): 83 | raise AssertionError("Expected string name. Received {}".format(name)) 84 | split_name = name.split(SEPARATOR) 85 | if len(split_name) != 2: 86 | raise AssertionError( 87 | "Expected a sting with a single occurrence of {}. Got {}".format(SEPARATOR, name) 88 | ) 89 | return split_name 90 | -------------------------------------------------------------------------------- /scripts/install_ubuntu_ci_core_dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2020-present Kensho Technologies, LLC. 3 | 4 | # Treat undefined variables and non-zero exits in pipes as errors. 5 | set -uo pipefail 6 | 7 | # Ensure that the "**" glob operator is applied recursively. 8 | # Make globs that do not match return null values. 9 | shopt -s globstar nullglob 10 | 11 | # Break on first error. 12 | set -e 13 | 14 | # This script is intended for use in the CI environment. 15 | # If it happens to work outside of CI as well, that is a pleasant but non-guaranteed side effect. 16 | # 17 | # Install all the binary dependencies needed on an Ubuntu system. 18 | bash -c "wget -qO- https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add -" 19 | sudo add-apt-repository "$(wget -qO- https://packages.microsoft.com/config/ubuntu/"$(lsb_release -r -s)"/prod.list)" 20 | sudo apt-get update 21 | sudo apt-get install unixodbc-dev python3-mysqldb libmysqlclient-dev 22 | ACCEPT_EULA=Y sudo apt-get install msodbcsql17 23 | 24 | # Ensure pip, setuptools, and pipenv are latest available versions. 25 | python -m pip install --upgrade pip 26 | python -m pip install --upgrade setuptools pipenv 27 | -------------------------------------------------------------------------------- /scripts/make_new_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2018-present Kensho Technologies, LLC. 3 | 4 | # Fail on first error, on undefined variables, and on errors in a pipeline. 5 | set -euo pipefail 6 | 7 | # Ensure that the "**" glob operator is applied recursively. 8 | # Make globs that do not match return null values. 9 | shopt -s globstar nullglob 10 | 11 | # Make sure the current working directory for this script is the root directory. 12 | cd "$(git -C "$(dirname "${0}")" rev-parse --show-toplevel )" 13 | 14 | current_branch=$(git rev-parse --abbrev-ref HEAD) 15 | if [[ "$current_branch" != 'main' ]]; then 16 | echo "Cannot make a release from a branch that is not 'main'. Current branch: $current_branch" 17 | exit 1 18 | fi 19 | 20 | # Clean up old release artifacts. Ignore errors since these directories might not exist. 21 | rm -r build/ dist/ || true 22 | 23 | # Build the source distribution. 24 | python setup.py sdist 25 | 26 | # Build the binary distribution. 27 | python setup.py bdist_wheel --universal 28 | 29 | # Upload the new release. 30 | twine upload dist/* 31 | 32 | # Clean up release artifacts, so they stop showing up in searches. 33 | rm -r build/ dist/ || true 34 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 100 6 | show-source = True 7 | select = 8 | E, 9 | F, 10 | W, 11 | Q, 12 | T, 13 | B 14 | ignore = 15 | E203, 16 | E231, 17 | W503 18 | exclude = 19 | .git, 20 | __pycache__, 21 | .pytest_cache, 22 | .mypy_cache, 23 | build, 24 | dist, 25 | venv, 26 | venv3, 27 | graphql_compiler/tests/snapshot_tests/snapshots/ 28 | 29 | [isort] 30 | line_length = 100 31 | multi_line_output = 3 32 | include_trailing_comma = True 33 | lines_after_imports = 2 34 | force_sort_within_sections = 1 35 | force_grid_wrap = 0 36 | combine_as_imports = True 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-present Kensho Technologies, LLC. 2 | import codecs 3 | import os 4 | import re 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | # https://packaging.python.org/guides/single-sourcing-package-version/ 10 | # #single-sourcing-the-version 11 | 12 | 13 | def read_file(filename: str) -> str: 14 | """Read package file as text to get name and version.""" 15 | # intentionally *not* adding an encoding option to open 16 | # see here: 17 | # https://github.com/pypa/virtualenv/issues/201#issuecomment-3145690 18 | here = os.path.abspath(os.path.dirname(__file__)) 19 | with codecs.open(os.path.join(here, "graphql_compiler", filename), "r") as f: 20 | return f.read() 21 | 22 | 23 | def find_version() -> str: 24 | """Only define version in one place.""" 25 | version_file = read_file("__init__.py") 26 | version_match = re.search(r'^__version__ = ["\']([^"\']*)["\']', version_file, re.M) 27 | if version_match: 28 | return version_match.group(1) 29 | raise RuntimeError("Unable to find version string.") 30 | 31 | 32 | def find_name() -> str: 33 | """Only define name in one place.""" 34 | name_file = read_file("__init__.py") 35 | name_match = re.search(r'^__package_name__ = ["\']([^"\']*)["\']', name_file, re.M) 36 | if name_match: 37 | return name_match.group(1) 38 | raise RuntimeError("Unable to find name string.") 39 | 40 | 41 | def find_long_description() -> str: 42 | """Return the content of the README.rst file.""" 43 | return read_file("../README.rst") 44 | 45 | 46 | setup( 47 | name=find_name(), 48 | version=find_version(), 49 | description="Turn complex GraphQL queries into optimized database queries.", 50 | long_description=find_long_description(), 51 | long_description_content_type="text/x-rst", 52 | url="https://github.com/kensho-technologies/graphql-compiler", 53 | author="Kensho Technologies, LLC.", 54 | author_email="graphql-compiler-maintainer@kensho.com", 55 | license="Apache 2.0", 56 | packages=find_packages(exclude=["tests*"]), 57 | install_requires=[ # Make sure to keep in sync with Pipfile requirements. 58 | "ciso8601>=2.1.3,<3", 59 | "dataclasses-json>=0.5.2,<0.6", 60 | "funcy>=1.7.3,<2", 61 | "graphql-core>=3.1.2,<3.2", 62 | "six>=1.10.0", 63 | "sqlalchemy>=1.3.0,<1.4", 64 | ], 65 | extras_require={ 66 | ':python_version<"3.7"': ["dataclasses>=0.7,<1"], 67 | ':python_version<"3.8"': [ 68 | "backports.cached-property>=1.0.0.post2,<2", 69 | "typing-extensions>=3.7.4.2,<4", 70 | ], 71 | }, 72 | package_data={"": ["py.typed"]}, 73 | classifiers=[ 74 | "Development Status :: 5 - Production/Stable", 75 | "Topic :: Database :: Front-Ends", 76 | "Topic :: Software Development :: Compilers", 77 | "Intended Audience :: Developers", 78 | "License :: OSI Approved :: Apache Software License", 79 | "Programming Language :: Python :: 3", 80 | "Programming Language :: Python :: 3.6", 81 | "Programming Language :: Python :: 3.7", 82 | "Programming Language :: Python :: 3.8", 83 | "Programming Language :: Python :: 3.9", 84 | ], 85 | keywords="graphql database compiler sql orientdb", 86 | python_requires=">=3.6", 87 | ) 88 | --------------------------------------------------------------------------------