diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..3de1f31 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +[ + plugins: [Styler], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..688138b --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +jonatan.maennchen@sustema.io. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla coc]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla coc]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..b08e50c --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,136 @@ +# Contributing to `senzing` + +## Welcome! + +We look forward to your contributions! Here are some examples how you can +contribute: + +- [Report a bug](https://github.com/sustema-ag/senzing-elixir/issues/new?labels=bug&template=BUG.md) +- [Propose a new feature](https://github.com/sustema-ag/senzing-elixir/issues/new?labels=enhancement&template=FEATURE.md) +- [Send a pull request](https://github.com/sustema-ag/senzing-elixir/pulls) + +## We have a Code of Conduct + +Please note that this project is released with a +[Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this +project you agree to abide by its terms. + +## Any contributions you make will be under the MIT License + +When you submit code changes, your submissions are understood to be under the +same [MIT](https://github.com/sustema-ag/senzing-elixir/blob/main/LICENSE) +that covers the project. By contributing to this project, you agree that your +contributions will be licensed under its MIT License. + +## Write bug reports with detail, background, and sample code + +In your bug report, please provide the following: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you +- tried that didn't work) + +Please do not report a bug for a version of `senzing-elixir` that is no longer +supported. Please do not report a bug if you are using a version of Erlang or +Elixir that is not supported by the version of `senzing-elixir` you are using. + +Please post code and output as text +([using proper markup](https://guides.github.com/features/mastering-markdown/)). +Do not post screenshots of code or output. + +## Workflow for Pull Requests + +1. Fork the repository. +2. Create your branch from `main` if you plan to implement new functionality or + change existing code significantly; create your branch from the oldest branch + that is affected by the bug if you plan to fix a bug. +3. Implement your change and add tests for it. +4. Ensure the test suite passes. +5. Ensure the code complies with our coding guidelines (see below). +6. Send that pull request! + +Please make sure you have +[set up your user name and email address](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup) +for use with Git. Strings such as `silly nick name ` look really +stupid in the commit history of a project. + +We encourage you to +[sign your Git commits with your GPG key](https://docs.github.com/en/github/authenticating-to-github/signing-commits). + +Pull requests for new features must be based on the `main` branch. + +We are trying to keep backwards compatibility breaks in `senzing` to a minimum. +Please take this into account when proposing changes. + +Due to time constraints, we are not always able to respond as quickly as we +would like. Please do not take delays personal and feel free to remind us if you +feel that we forgot to respond. + +## Coding Guidelines + +This project comes with configured linters (located in `.credo.exs` in the +repository) that you can use to perform various checks: + +```bash +$ mix credo +``` + +This project comes with configuration (located in `.formatter.exs` in the +repository) that you can use to (re)format your +source code for compliance with this project's coding guidelines: + +```bash +$ mix format +``` + +This project uses `dialyzer` to perform static code checking. Run it to make +sure that your code is valid: + +```bash +$ mix dialyzer +``` + +Please understand that we will not accept a pull request when its changes +violate this project's coding guidelines. + +## Using `senzing` from a Git checkout + +The following commands can be used to perform the initial checkout of +`senzing`: + +```bash +$ git clone git@github.com:sustema-ag/senzing-elixir.git + +$ cd senzing-elixir +``` + +Install `senzing`'s dependencies using [mix](https://hexdocs.pm/mix/Mix.html): + +```bash +$ mix deps.get +``` + +## Running `senzing`'s test suite + +After following the steps shown above, `senzing`'s test suite is run like +this: + +```bash +$ mix test +``` + +## Generating `senzing` Documentation + +To generate the documentation for the library, run: + +```bash +$ mix docs +``` + + + diff --git a/.github/ISSUE_TEMPLATE/BUG.yml b/.github/ISSUE_TEMPLATE/BUG.yml new file mode 100644 index 0000000..a2a1398 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG.yml @@ -0,0 +1,65 @@ +name: 🐞 Bug Report +description: Something is broken? +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + - Create a discussion instead if you are looking for support: + https://github.com/sustema-ag/senzing-elixir/discussions + - type: input + id: version + attributes: + label: senzing version + placeholder: x.y.z + validations: + required: true + - type: input + id: version + attributes: + label: senzing-elixir version + placeholder: x.y.z + validations: + required: true + - type: input + id: erlang-version + attributes: + label: Erlang version + placeholder: x.y.z + validations: + required: true + - type: input + id: elixir-version + attributes: + label: Elixir version + placeholder: x.y.z + validations: + required: true + - type: textarea + id: summary + attributes: + label: Summary + description: Provide a summary describing the problem you are experiencing. + validations: + required: true + - type: textarea + id: current-behaviour + attributes: + label: Current behavior + description: What is the current (buggy) behavior? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: How to reproduce + description: Provide steps to reproduce the bug. + validations: + required: true + - type: textarea + id: expected-behaviour + attributes: + label: Expected behavior + description: What was the expected (correct) behavior? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/FEATURE.yml b/.github/ISSUE_TEMPLATE/FEATURE.yml new file mode 100644 index 0000000..e5dec63 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE.yml @@ -0,0 +1,11 @@ +name: 🎉 Feature Request +description: You have a neat idea that should be implemented? +labels: ["enhancement"] +body: + - type: textarea + id: description + attributes: + label: Description + description: Provide a summary of the feature you would like to see implemented. + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e97514b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ +Please go the the `Preview` tab and select the appropriate sub-template: + +- [🐞 Bug Fix](?expand=1&template=FIX.md) +- [⚙ Improvement](?expand=1&template=IMPROVEMENT.md) +- [🎉 New Feature](?expand=1&template=NEW_FEATURE.md) diff --git a/.github/PULL_REQUEST_TEMPLATE/FIX.md b/.github/PULL_REQUEST_TEMPLATE/FIX.md new file mode 100644 index 0000000..c8aff43 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/FIX.md @@ -0,0 +1,10 @@ + + + diff --git a/.github/PULL_REQUEST_TEMPLATE/IMPROVEMENT.md b/.github/PULL_REQUEST_TEMPLATE/IMPROVEMENT.md new file mode 100644 index 0000000..aa69f17 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/IMPROVEMENT.md @@ -0,0 +1,9 @@ + + + diff --git a/.github/PULL_REQUEST_TEMPLATE/NEW_FEATURE.md b/.github/PULL_REQUEST_TEMPLATE/NEW_FEATURE.md new file mode 100644 index 0000000..69e1b94 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/NEW_FEATURE.md @@ -0,0 +1,9 @@ + + + diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..435bb1f --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +[![OpenSSF Vulnerability Disclosure](https://img.shields.io/badge/OpenSSF-Vulnerability_Disclosure-green)](https://github.com/ossf/oss-vulnerability-guide/blob/main/finder-guide.md) +[![GitHub Report](https://img.shields.io/badge/GitHub-Security_Advisories-blue)](https://github.com/sustema-ag/senzing-elixir/security/advisories/new) +[![Email Report](https://img.shields.io/badge/Email-jonatan.maennchen%40sustema.io-blue)](mailto:jonatan.maennchen@sustema.io) + +This repository follows the +[OpenSSF Vulnerability Disclosure guide](https://github.com/ossf/oss-vulnerability-guide/tree/main). +You can learn more about it in the +[Finders Guide](https://github.com/ossf/oss-vulnerability-guide/blob/main/finder-guide.md). + +Please report vulnerabilities via the +[GitHub Security Vulnerability Reporting](https://github.com/sustema-ag/senzing-elixir/security/advisories/new) +or via email to [`jonatan.maennchen@sustema.io`](mailto:jonatan.maennchen@sustema.io) +if this does not work for you. + +Our vulnerability management team will respond within 3 working days of your +report. If the issue is confirmed as a vulnerability, we will open a Security +Advisory. This project follows a 90 day disclosure timeline. + +If you have questions about reporting security issues, email the vulnerability +management team: [`jonatan.maennchen@sustema.io`](mailto:jonatan.maennchen@sustema.io) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1d71b35 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: mix + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..5ca6257 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,3 @@ +{ + "enabledManagers": ["asdf"] +} diff --git a/.github/workflows/branch_main.yml b/.github/workflows/branch_main.yml new file mode 100644 index 0000000..c48c5ba --- /dev/null +++ b/.github/workflows/branch_main.yml @@ -0,0 +1,22 @@ +on: + push: + branches: + - "main" + +name: "Main Branch" + +jobs: + test: + name: "Test" + + uses: ./.github/workflows/part_test.yml + + docs: + name: "Docs" + + uses: ./.github/workflows/part_docs.yml + + publish: + name: "Publish" + + uses: ./.github/workflows/part_publish.yml diff --git a/.github/workflows/part_docs.yml b/.github/workflows/part_docs.yml new file mode 100644 index 0000000..22edfc4 --- /dev/null +++ b/.github/workflows/part_docs.yml @@ -0,0 +1,74 @@ +on: + workflow_call: + inputs: + releaseName: + required: false + type: string + +name: "Documentation" + +jobs: + generate: + name: "Generate" + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-elixir@v1 + id: setupBEAM + with: + version-file: .tool-versions + version-type: strict + - name: Install Senzing API + uses: senzing-factory/github-action-install-senzing-api@v3 + with: + senzingapi-runtime-version: production-v3 + - uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.11.0 + - uses: actions/cache@v4 + with: + path: _build + key: docs-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('rebar.config') }} + restore-keys: | + docs-build-{{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - uses: actions/cache@v4 + with: + path: deps + key: docs-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('rebar.config') }} + restore-keys: | + docs-bdepsuild-{{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - run: mix deps.get + - run: mix docs + - uses: actions/upload-artifact@v4 + with: + name: docs + path: doc + + upload: + name: "Upload" + + runs-on: ubuntu-latest + + if: ${{ inputs.releaseName }} + + needs: ["generate"] + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: docs + path: docs + - run: | + tar -czvf docs.tar.gz docs + - name: Upload + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + gh release upload --clobber "${{ inputs.releaseName }}" \ + docs.tar.gz diff --git a/.github/workflows/part_publish.yml b/.github/workflows/part_publish.yml new file mode 100644 index 0000000..54d5326 --- /dev/null +++ b/.github/workflows/part_publish.yml @@ -0,0 +1,99 @@ +on: + workflow_call: + inputs: + releaseName: + required: false + type: string + secrets: + HEX_API_KEY: + required: false + +name: "Publish" + +jobs: + hex_publish: + name: mix hex.publish + + runs-on: ubuntu-latest + + if: "${{ inputs.releaseName }}" + + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + id: setupBEAM + with: + version-file: .tool-versions + version-type: strict + - uses: actions/cache@v4 + with: + path: _build + key: mix_hex_publish-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + mix_hex_publish-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}- + - uses: actions/cache@v4 + with: + path: deps + key: mix_hex_publish-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + mix_hex_publish-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}- + - run: mix deps.get + - run: mix hex.publish --yes + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + + hex_build: + name: mix hex.build + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + id: setupBEAM + with: + version-file: .tool-versions + version-type: strict + - uses: actions/cache@v4 + with: + path: _build + key: mix_hex_build-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + mix_hex_build-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}- + - uses: actions/cache@v4 + with: + path: deps + key: mix_hex_build-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + mix_hex_build-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}- + - run: mix deps.get + - run: mix hex.build --output package.tar + - uses: actions/upload-artifact@v4 + with: + name: package + path: package.tar + + upload: + name: "Upload" + + runs-on: ubuntu-latest + + if: ${{ inputs.releaseName }} + + needs: ["hex_build"] + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: package + path: . + - name: Upload + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + gh release upload --clobber "${{ inputs.releaseName }}" \ + package.tar diff --git a/.github/workflows/part_release.yml b/.github/workflows/part_release.yml new file mode 100644 index 0000000..7b2147d --- /dev/null +++ b/.github/workflows/part_release.yml @@ -0,0 +1,56 @@ +on: + workflow_call: + inputs: + releaseName: + required: true + type: string + stable: + required: false + type: boolean + default: false + +name: "Release" + +jobs: + create_prerelease: + name: Create Prerelease + + if: ${{ !inputs.stable }} + + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Create draft prerelease + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + gh release create \ + --repo ${{ github.repository }} \ + --title ${{ inputs.releaseName }} \ + --prerelease \ + --generate-notes \ + ${{ inputs.releaseName }} + + create_stable: + name: Create Stable + + if: ${{ inputs.stable }} + + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Create draft release + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + gh release create \ + --repo ${{ github.repository }} \ + --title ${{ inputs.releaseName }} \ + --generate-notes \ + ${{ inputs.releaseName }} diff --git a/.github/workflows/part_test.yml b/.github/workflows/part_test.yml new file mode 100644 index 0000000..dc25a30 --- /dev/null +++ b/.github/workflows/part_test.yml @@ -0,0 +1,180 @@ +on: + workflow_call: {} + +name: "Test" + +jobs: + detectToolVersions: + name: "Detect Tool Versions" + + runs-on: ubuntu-latest + + outputs: + otpVersion: "${{ steps.toolVersions.outputs.OTP_VERSION }}" + elixirVersion: "${{ steps.toolVersions.outputs.ELIXIR_VERSION }}" + + steps: + - uses: actions/checkout@v4 + - name: "Read .tool-versions" + id: toolVersions + run: | + OTP_VERSION="$(cat .tool-versions | grep erlang | cut -d' ' -f2-)" + echo OTP: $OTP_VERSION + echo "OTP_VERSION=${OTP_VERSION}" >> $GITHUB_OUTPUT + + ELIXIR_VERSION="$(cat .tool-versions | grep elixir | cut -d' ' -f2-)" + echo Rebar: $ELIXIR_VERSION + echo "ELIXIR_VERSION=${ELIXIR_VERSION}" >> $GITHUB_OUTPUT + + mix_format: + name: mix format + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + id: setupBEAM + with: + version-file: .tool-versions + version-type: strict + - uses: actions/cache@v4 + with: + path: _build + key: mix_format-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + mix_format-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - uses: actions/cache@v4 + with: + path: deps + key: mix_format-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + mix_format-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - run: mix deps.get + - run: mix format --check-formatted + + mix_test: + name: mix test (${{ matrix.elixir }}) + + runs-on: ubuntu-latest + + needs: ["detectToolVersions"] + + strategy: + fail-fast: false + matrix: + include: + # Lowest Supported Version + - elixir: "1.17.0" + otp: "27.0" + unstable: false + # Latest Supported Version (via ASDF) + - elixir: "${{ needs.detectToolVersions.outputs.elixirVersion }}" + otp: "${{ needs.detectToolVersions.outputs.otpVersion }}" + unstable: false + # Elixir Main + - elixir: "main" + otp: "${{ needs.detectToolVersions.outputs.otpVersion }}" + unstable: true + + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + id: setupBEAM + with: + otp-version: "${{ matrix.otp }}" + elixir-version: "${{ matrix.elixir }}" + version-type: strict + - name: Install Senzing API + uses: senzing-factory/github-action-install-senzing-api@v3 + with: + senzingapi-runtime-version: production-v3 + - uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.11.0 + - uses: actions/cache@v4 + with: + path: _build + key: mix_test-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + mix_test-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - uses: actions/cache@v4 + with: + path: deps + key: mix_test-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + mix_test-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - run: mix deps.get + - run: | + mix compile; + ls /opt/senzing/g2/lib/libG2.so; + ls /tmp/Elixir.Senzing.G2.Config.Nif/; + cat /tmp/Elixir.Senzing.G2.Config.Nif/build.zig; + - run: mix coveralls.multiple --type html --type github + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/upload-artifact@v4 + with: + name: mix_test-coverage-${{ matrix.elixir }} + path: cover/ + + credo: + name: mix credo + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + id: setupBEAM + with: + version-file: .tool-versions + version-type: strict + - uses: actions/cache@v4 + with: + path: _build + key: credo-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + credo-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - uses: actions/cache@v4 + with: + path: deps + key: credo-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + credo-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - run: mix deps.get + - run: mix credo + + dialyxir: + name: mix dialyzer + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + id: setupBEAM + with: + version-file: .tool-versions + version-type: strict + - name: Install Senzing API + uses: senzing-factory/github-action-install-senzing-api@v3 + with: + senzingapi-runtime-version: production-v3 + - uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.11.0 + - uses: actions/cache@v4 + with: + path: _build + key: dialyxir-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + dialyxir-build-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - uses: actions/cache@v4 + with: + path: deps + key: dialyxir-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}-${{ hashFiles('mix.exs') }} + restore-keys: | + dialyxir-deps-${{ runner.os }}-${{ steps.setupBEAM.outputs.otp-version }}-${{ steps.setupBEAM.outputs.elixir-version }}- + - run: mix deps.get + - run: mix dialyzer diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..44b55b0 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,23 @@ +on: + pull_request: + branches: + - "*" + workflow_dispatch: {} + +name: "Pull Request" + +jobs: + test: + name: "Test" + + uses: ./.github/workflows/part_test.yml + + docs: + name: "Docs" + + uses: ./.github/workflows/part_docs.yml + + publish: + name: "Publish" + + uses: ./.github/workflows/part_publish.yml diff --git a/.github/workflows/tag-beta.yml b/.github/workflows/tag-beta.yml new file mode 100644 index 0000000..ce914cb --- /dev/null +++ b/.github/workflows/tag-beta.yml @@ -0,0 +1,36 @@ +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" + +name: "Beta Tag" + +jobs: + release: + name: "Release" + + uses: ./.github/workflows/part_release.yml + with: + releaseName: "${{ github.ref_name }}" + + docs: + name: "Docs" + + needs: ["release"] + + uses: ./.github/workflows/part_docs.yml + with: + releaseName: "${{ github.ref_name }}" + + publish: + name: "Publish" + + needs: ["release"] + + uses: ./.github/workflows/part_publish.yml + with: + releaseName: "${{ github.ref_name }}" + secrets: + HEX_API_KEY: "${{ secrets.HEX_API_KEY }}" diff --git a/.github/workflows/tag-stable.yml b/.github/workflows/tag-stable.yml new file mode 100644 index 0000000..6c5563e --- /dev/null +++ b/.github/workflows/tag-stable.yml @@ -0,0 +1,35 @@ +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +name: "Stable Tag" + +jobs: + release: + name: "Release" + + uses: ./.github/workflows/part_release.yml + with: + releaseName: "${{ github.ref_name }}" + stable: true + + docs: + name: "Docs" + + needs: ["release"] + + uses: ./.github/workflows/part_docs.yml + with: + releaseName: "${{ github.ref_name }}" + + publish: + name: "Publish" + + needs: ["release"] + + uses: ./.github/workflows/part_publish.yml + with: + releaseName: "${{ github.ref_name }}" + secrets: + HEX_API_KEY: "${{ secrets.HEX_API_KEY }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d72f3ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +senzing-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +# Autogenerated .zig files +/lib/**/.Elixir.*.zig + +# Env Files +/.env \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..a9ae129 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +zig 0.11.0 +erlang 27.0.1 +elixir 1.17.2 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04cd5a9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2024 Sustema AG, 2024 Jonatan Männchen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2f59eb --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Senzing Elixir NIF + +[![Main Branch](https://github.com/sustema-ag/senzing-elixir/actions/workflows/branch_main.yml/badge.svg?branch=main)](https://github.com/sustema-ag/senzing-elixir/actions/workflows/branch_main.yml) +[![Module Version](https://img.shields.io/hexpm/v/senzing.svg)](https://hex.pm/packages/senzing) +[![Total Download](https://img.shields.io/hexpm/dt/senzing.svg)](https://hex.pm/packages/senzing) +[![License](https://img.shields.io/hexpm/l/senzing.svg)](https://github.com/sustema-ag/senzing-elixir/blob/main/LICENSE) +[![Last Updated](https://img.shields.io/github/last-commit/sustema-ag/senzing-elixir.svg)](https://github.com/sustema-ag/senzing-elixir/commits/master) +[![Coverage Status](https://coveralls.io/repos/github/sustema-ag/senzing-elixir/badge.svg?branch=main)](https://coveralls.io/github/sustema-ag/senzing-elixir?branch=main) + + + +Elixir NIF for [Senzing©](https://senzing.com/) Entity Matching. + +
+ + + + + Senzing Logo + + +This package is providing an interface for the [Senzing©](https://senzing.com/) +C SDK. + +
+ + + + + Sustema Logo + + +This library was developed for free by [Sustema AG](https://sustema.io). + +
+ +## Installation + +To be able to run this package, Senzing has to be installed by following the +Linux setup guide: + +The path used in +[`G2CreateProject`](https://docs.senzing.com/quickstart/quickstart_api/#create-a-senzing-project) +has to be set as an environment variable `SENZING_ROOT`. + +The package can be installed by adding `senzing` to your list of dependencies +in `mix.exs`: + +```elixir +def deps do + [ + {:senzing, "~> 0.1.0"} + ] +end +``` + +## Docs + +* NIF: +* Senzing Developer Docs: diff --git a/assets/COPYRIGHT b/assets/COPYRIGHT new file mode 100644 index 0000000..4f9cd48 --- /dev/null +++ b/assets/COPYRIGHT @@ -0,0 +1,7 @@ +Please be advised that the logo files contained within the assets directory are +not subject to the project's license. These logo files are protected under their +respective company copyrights. All rights associated with these logos are +retained by the respective companies, and their usage is governed by the terms +and conditions set forth by those companies. + +TODO: Check if it's ok to use the Senzing logo with Senzing. \ No newline at end of file diff --git a/assets/senzing-logo-dark.png b/assets/senzing-logo-dark.png new file mode 100644 index 0000000..60dd7bc Binary files /dev/null and b/assets/senzing-logo-dark.png differ diff --git a/assets/senzing-logo-light.png b/assets/senzing-logo-light.png new file mode 100644 index 0000000..205d368 Binary files /dev/null and b/assets/senzing-logo-light.png differ diff --git a/assets/senzing-logo-small.png b/assets/senzing-logo-small.png new file mode 100644 index 0000000..6773c19 Binary files /dev/null and b/assets/senzing-logo-small.png differ diff --git a/assets/sustema-logo-dark.svg b/assets/sustema-logo-dark.svg new file mode 100644 index 0000000..b718f7d --- /dev/null +++ b/assets/sustema-logo-dark.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/sustema-logo-light.svg b/assets/sustema-logo-light.svg new file mode 100644 index 0000000..8868310 --- /dev/null +++ b/assets/sustema-logo-light.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/.credo.exs b/config/.credo.exs new file mode 100644 index 0000000..e64616f --- /dev/null +++ b/config/.credo.exs @@ -0,0 +1,217 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/" + ], + excluded: [~r"/_build/", ~r"/deps/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: true, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.WrongTestFileExtension, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Readability.Specs, []} + ], + disabled: [ + # Unused Checks + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/lib/senzing.ex b/lib/senzing.ex new file mode 100644 index 0000000..33be1e4 --- /dev/null +++ b/lib/senzing.ex @@ -0,0 +1,21 @@ +readme_path = Path.join([Path.dirname(__ENV__.file), "..", "README.md"]) + +defmodule Senzing do + @moduledoc readme_path |> File.read!() |> String.split("") |> Enum.fetch!(1) + + @external_resource readme_path + + @doc false + @spec locate_root_path() :: Path.t() + def locate_root_path do + path = System.get_env("SENZING_ROOT", Application.get_env(:senzing, :root_path, "/opt/senzing")) + + unless File.exists?(Path.join(path, "data")) do + raise """ + Invalid Senzing Root Path: #{path} + """ + end + + path + end +end diff --git a/lib/senzing/application.ex b/lib/senzing/application.ex new file mode 100644 index 0000000..1afd886 --- /dev/null +++ b/lib/senzing/application.ex @@ -0,0 +1,54 @@ +defmodule Senzing.Application do + @moduledoc false + + use Application + + alias Senzing.G2.ResourceInit + + @impl true + def start(_type, args) do + children = + case g2_start_opts(args) do + {:ok, options} -> [{ResourceInit, options}] + :error -> [] + end + + Supervisor.start_link( + children, + strategy: :one_for_one, + name: Senzing.Supervisor + ) + end + + @spec g2_start_opts(args :: Keyword.t()) :: {:ok, ResourceInit.options()} | :error + defp g2_start_opts(args) do + with {:ok, mod} <- Keyword.fetch(args, :mod) do + {:ok, + [mod: mod] + |> then(fn opts -> + case Keyword.fetch(args, :ini_params) do + :error -> opts + {:ok, params} -> Keyword.put(opts, :ini_params, params) + end + end) + |> then(fn opts -> + case Application.fetch_env(:senzing, :engine_name) do + :error -> opts + {:ok, name} -> Keyword.put(opts, :name, name) + end + end) + |> then(fn opts -> + case Application.fetch_env(:senzing, :verbose_logging?) do + :error -> opts + {:ok, verbose_logging?} -> Keyword.put(opts, :verbose_logging, verbose_logging?) + end + end) + |> then(fn opts -> + case Application.fetch_env(:senzing, :prime) do + :error -> opts + {:ok, prime?} -> Keyword.put(opts, :prime, prime?) + end + end)} + end + end +end diff --git a/lib/senzing/g2.ex b/lib/senzing/g2.ex new file mode 100644 index 0000000..af82b60 --- /dev/null +++ b/lib/senzing/g2.ex @@ -0,0 +1,35 @@ +defmodule Senzing.G2 do + @moduledoc """ + Shared functionality for Senzing G2 + """ + + @type error() :: {code :: integer(), message :: String.t()} + @type result() :: :ok | {:error, reason :: error()} + @type result(t) :: {:ok, t} | {:error, reason :: error()} + + @doc false + @spec locate_sdk_path() :: Path.t() + def locate_sdk_path, do: locate_g2_path("sdk/c") + + @doc false + @spec locate_lib_path() :: Path.t() + def locate_lib_path, do: locate_g2_path("lib") + + @spec locate_g2_path(subpath :: Path.t()) :: Path.t() + defp locate_g2_path(subpath) do + root_path = Senzing.locate_root_path() + + cond do + File.exists?(Path.join([root_path, "g2", subpath])) -> + Path.join([root_path, "g2", subpath]) + + File.exists?(Path.join(root_path, subpath)) -> + Path.join(root_path, subpath) + + true -> + raise """ + Could not locate #{subpath} in #{root_path} + """ + end + end +end diff --git a/lib/senzing/g2/config.ex b/lib/senzing/g2/config.ex new file mode 100644 index 0000000..af03de4 --- /dev/null +++ b/lib/senzing/g2/config.ex @@ -0,0 +1,219 @@ +defmodule Senzing.G2.Config do + @moduledoc """ + G2 Config NIF Functionality + + See https://docs.senzing.com/python/3/g2config/index.html + + To use any of these functions, make sure to start `Senzing.G2.ResourceInit` + with the `mod` option set to `#{__MODULE__}`. + """ + + @behaviour Senzing.G2.ResourceInit + + use GenServer + + alias Senzing.G2 + alias Senzing.G2.Config.Nif + alias Senzing.G2.ResourceInit + + @typedoc """ + Serialized Config + + > #### Opaque Config {: .warning} + > + > This should only be serialized in files and not manipulated directly. Use the + > functions of this module to alter the configuration. + """ + @type t() :: String.t() + + @typedoc """ + Data Source Configuration + + See https://docs.senzing.com/python/3/g2config/ + """ + @type data_source() :: map() + + @type resource_init_option() :: + {:verbose_logging, boolean()} | {:prime, boolean()} | {:config_id, integer()} + @type resource_init_options() :: [resource_init_option()] + + @typedoc """ + Start Option + + * `name` - GenServer name + * `load` - Load a `t:#{__MODULE__}.t/0`. See https://docs.senzing.com/python/3/g2config/#load + """ + @type option() :: {:load, t()} | {:name, GenServer.name()} + + @typedoc """ + Start Options + + See `t:#{__MODULE__}.option/0` + """ + @type options() :: [option()] + + ############################################################################## + # GenServer Start / Callbacks + ############################################################################## + + @doc """ + Start the configuration + + See `t:#{__MODULE__}.option/0` for available options. + """ + @spec start_link(opts :: keyword()) :: GenServer.on_start() + def start_link(opts), do: GenServer.start_link(__MODULE__, Keyword.take(opts, [:load]), Keyword.take(opts, [:name])) + + @doc false + @impl GenServer + def init(opts) do + Process.flag(:trap_exit, true) + + opts + |> Keyword.fetch(:load) + |> case do + {:ok, config} -> Nif.load_it(config) + :error -> Nif.create() + end + |> case do + {:ok, config} -> {:ok, config} + {:error, reason} -> {:stop, reason} + end + end + + @doc false + @impl GenServer + def terminate(_reason, config) do + Nif.close(config) + end + + @doc false + @impl GenServer + def handle_call(:list_data_sources, _from, config) do + {:reply, + with( + {:ok, response} <- Nif.list_data_sources(config), + %{"DATA_SOURCES" => data_sources} <- :json.decode(response), + do: {:ok, data_sources} + ), config} + end + + def handle_call({:add_data_source, data_source}, _from, config) do + {:reply, + with( + {:ok, response} <- Nif.add_data_source(config, IO.iodata_to_binary(:json.encode(data_source))), + do: {:ok, :json.decode(response)} + ), config} + end + + def handle_call({:delete_data_source, data_source}, _from, config), + do: {:reply, Nif.delete_data_source(config, IO.iodata_to_binary(:json.encode(data_source))), config} + + def handle_call(:save, _from, config) do + {:reply, Nif.save(config), config} + end + + ############################################################################## + # ResourceInit Callbacks + ############################################################################## + + # This method will initialize the G2 Config object. + # + # It must be called prior to any other calls. + # + # Usually you will want to start the config by starting the `senzing` + # application or by starting `Senzing.G2.Init` module as a worker. + @doc false + @impl ResourceInit + @spec resource_init( + name :: String.t(), + ini_params :: ResourceInit.ini_params(), + options :: resource_init_options() + ) :: G2.result() + def resource_init(name, config, options \\ []) when is_binary(name) and is_map(config), + do: Nif.init(name, IO.iodata_to_binary(:json.encode(config)), options[:verbose_logging] || false) + + # This method will destroy and perform cleanup for the G2 Config object. + # + # It should be called after all other calls are complete. + @doc false + @impl ResourceInit + @spec resource_destroy() :: G2.result() + defdelegate resource_destroy, to: Nif, as: :destroy + + ############################################################################## + # Exposed Functions + ############################################################################## + + @doc """ + Exports an in-memory configuration as a JSON string. + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, _json} = Senzing.G2.Config.save(config) + iex> # {:ok, "{\"G2_CONFIG\": \"...\"}"} + + """ + @spec save(server :: GenServer.server()) :: G2.result(t()) + def save(server), do: GenServer.call(server, :save) + + @doc """ + Returns a list of data sources contained in an in-memory configuration. + + See https://docs.senzing.com/python/3/g2config/#listdatasources + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, _data_sources} = Senzing.G2.Config.list_data_sources(config) + {:ok, [ + %{"DSRC_CODE" => "TEST", "DSRC_ID" => 1}, + %{"DSRC_CODE" => "SEARCH", "DSRC_ID" => 2} + ]} + + """ + @spec list_data_sources(server :: GenServer.server()) :: G2.result([data_source()]) + def list_data_sources(server), do: GenServer.call(server, :list_data_sources) + + @doc """ + Add Data Source + + See https://docs.senzing.com/python/3/g2config/#adddatasource + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, _data_source} = Senzing.G2.Config.add_data_source(config, %{"DSRC_CODE" => "NAME_OF_DATASOURCE"}) + iex> {:ok, _data_sources} = Senzing.G2.Config.list_data_sources(config) + {:ok, [ + %{"DSRC_CODE" => "TEST", "DSRC_ID" => 1}, + %{"DSRC_CODE" => "SEARCH", "DSRC_ID" => 2}, + %{"DSRC_CODE" => "NAME_OF_DATASOURCE", "DSRC_ID" => 1001} + ]} + + """ + @spec add_data_source(server :: GenServer.server(), data_source :: data_source()) :: + G2.result(data_source()) + def add_data_source(server, data_source), do: GenServer.call(server, {:add_data_source, data_source}) + + @doc """ + Delete Data Source + + See https://docs.senzing.com/python/3/g2config/#deletedatasource + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, _data_source} = Senzing.G2.Config.add_data_source(config, %{"DSRC_CODE" => "NAME_OF_DATASOURCE"}) + iex> :ok = Senzing.G2.Config.delete_data_source(config, %{"DSRC_CODE" => "NAME_OF_DATASOURCE"}) + iex> {:ok, _data_sources} = Senzing.G2.Config.list_data_sources(config) + {:ok, [ + %{"DSRC_CODE" => "TEST", "DSRC_ID" => 1}, + %{"DSRC_CODE" => "SEARCH", "DSRC_ID" => 2} + ]} + + """ + @spec delete_data_source(server :: GenServer.server(), data_source :: data_source()) :: G2.result() + def delete_data_source(server, data_source), do: GenServer.call(server, {:delete_data_source, data_source}) +end diff --git a/lib/senzing/g2/config/nif.ex b/lib/senzing/g2/config/nif.ex new file mode 100644 index 0000000..bc15219 --- /dev/null +++ b/lib/senzing/g2/config/nif.ex @@ -0,0 +1,167 @@ +defmodule Senzing.G2.Config.Nif do + @moduledoc false + + use Senzing.Nif, resources: [:ConfigResource] + + ~z""" + const beam = @import("beam"); + const G2Config = @cImport(@cInclude("libg2config.h")); + const root = @import("root"); + + pub const ConfigResource = beam.Resource(G2Config.ConfigHandle, root, .{}); + + fn get_and_clear_last_exception(env: beam.env) !beam.term { + var slice = try beam.allocator.alloc(u8, 1024); + defer beam.allocator.free(slice); + + var size: usize = @intCast(G2Config.G2Config_getLastException(slice.ptr, 1024)); + + if (size == 0) { + return beam.make(env, .unknown_error, .{}); + } + + // Size contains zero byte + slice = try beam.allocator.realloc(slice, size - 1); + + var code = G2Config.G2Config_getLastExceptionCode(); + + G2Config.G2Config_clearLastException(); + + return beam.make(env, .{ code, slice }, .{}); + } + + fn resize_pointer(ptr: ?*anyopaque, size: usize) callconv(.C) ?*anyopaque { + _ = ptr; + const newPtr = beam.allocator.alloc([*c]u8, size) catch return null; + return @as(*anyopaque, @ptrCast(newPtr.ptr)); + } + + pub fn init(env: beam.env, name: []u8, ini_params: []u8, verbose_logging: bool) !beam.term { + var g2_name = try beam.allocator.dupeZ(u8, name); + var g2_ini_params = try beam.allocator.dupeZ(u8, ini_params); + var g2_verbose_logging: i8 = if(verbose_logging) 1 else 0; + + if (G2Config.G2Config_init(g2_name, g2_ini_params, g2_verbose_logging) != 0) { + var reason = try get_and_clear_last_exception(env); + _ = G2Config.G2Config_destroy(); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + + pub fn create(env: beam.env) !beam.term { + var handle: G2Config.ConfigHandle = null; + + if(G2Config.G2Config_create(&handle) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + const resource = try ConfigResource.create(handle, .{}); + + return beam.make(env, .{.@"ok", resource}, .{}); + } + + pub fn save(env: beam.env, config: beam.term) !beam.term { + const res = try beam.get(ConfigResource, env, config, .{}); + const handle = res.unpack(); + + var bufSize: usize = 1024; + var initialResponseBuf = try beam.allocator.alloc(u8, bufSize); + defer beam.allocator.free(initialResponseBuf); + var responseBuf: [*c]u8 = initialResponseBuf.ptr; + + if(G2Config.G2Config_save(handle, &responseBuf, &bufSize, resize_pointer) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", responseBuf}, .{}); + } + + pub fn load_it(env: beam.env, json: []u8) !beam.term { + var handle: G2Config.ConfigHandle = null; + var g2_json = try beam.allocator.dupeZ(u8, json); + + if(G2Config.G2Config_load(g2_json, &handle) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + const resource = try ConfigResource.create(handle, .{}); + + return beam.make(env, .{.@"ok", resource}, .{}); + } + + pub fn list_data_sources(env: beam.env, config: beam.term) !beam.term { + const res = try beam.get(ConfigResource, env, config, .{}); + const handle = res.unpack(); + + var bufSize: usize = 1024; + var initialResponseBuf = try beam.allocator.alloc(u8, bufSize); + defer beam.allocator.free(initialResponseBuf); + var responseBuf: [*c]u8 = initialResponseBuf.ptr; + + if(G2Config.G2Config_listDataSources(handle, &responseBuf, &bufSize, resize_pointer) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", responseBuf}, .{}); + } + + pub fn add_data_source(env: beam.env, config: beam.term, data_source: []u8) !beam.term { + const res = try beam.get(ConfigResource, env, config, .{}); + const handle = res.unpack(); + var g2_data_source = try beam.allocator.dupeZ(u8, data_source); + + var bufSize: usize = 1024; + var initialResponseBuf = try beam.allocator.alloc(u8, bufSize); + defer beam.allocator.free(initialResponseBuf); + var responseBuf: [*c]u8 = initialResponseBuf.ptr; + + if(G2Config.G2Config_addDataSource(handle, g2_data_source, &responseBuf, &bufSize, resize_pointer) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", responseBuf}, .{}); + } + + pub fn delete_data_source(env: beam.env, config: beam.term, data_source: []u8) !beam.term { + const res = try beam.get(ConfigResource, env, config, .{}); + const handle = res.unpack(); + var g2_data_source = try beam.allocator.dupeZ(u8, data_source); + + if(G2Config.G2Config_deleteDataSource(handle, g2_data_source) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + + pub fn close(env: beam.env, config: beam.term) !beam.term { + const res = try beam.get(ConfigResource, env, config, .{}); + var handle = res.unpack(); + + if(G2Config.G2Config_close(handle) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + res.update(null); + + return beam.make(env, .@"ok", .{}); + } + + pub fn destroy(env: beam.env) !beam.term { + if(G2Config.G2Config_destroy() != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + return beam.make(env, .@"ok", .{}); + } + """ +end diff --git a/lib/senzing/g2/config_manager.ex b/lib/senzing/g2/config_manager.ex new file mode 100644 index 0000000..7259365 --- /dev/null +++ b/lib/senzing/g2/config_manager.ex @@ -0,0 +1,163 @@ +defmodule Senzing.G2.ConfigManager do + @moduledoc """ + The G2 Config Manager modifies Senzing configurations in the Senzing database. + + See https://docs.senzing.com/python/3/g2configmgr/index.html + + To use any of these functions, make sure to start `Senzing.G2.ResourceInit` + with the `mod` option set to `#{__MODULE__}`. + """ + + @behaviour Senzing.G2.ResourceInit + + alias Senzing.G2 + alias Senzing.G2.Config + alias Senzing.G2.ConfigManager.Nif + alias Senzing.G2.ResourceInit + + @type resource_init_option() :: {:verbose_logging, boolean()} + @type resource_init_options() :: [resource_init_option()] + + @type config_id :: integer() + @type config_parameters :: map() + + ############################################################################## + # ResourceInit Callbacks + ############################################################################## + + # This method will initialize the G2 Config object. + # + # It must be called prior to any other calls. + # + # Usually you will want to start the config by starting the `senzing` + # application or by starting `Senzing.G2.Init` module as a worker. + @doc false + @impl ResourceInit + @spec resource_init( + name :: String.t(), + ini_params :: ResourceInit.ini_params(), + options :: resource_init_options() + ) :: G2.result() + def resource_init(name, ini_params, options \\ []) when is_binary(name) and is_map(ini_params), + do: Nif.init(name, IO.iodata_to_binary(:json.encode(ini_params)), options[:verbose_logging] || false) + + # This method will destroy and perform cleanup for the G2 Config object. + # + # It should be called after all other calls are complete. + @doc false + @impl ResourceInit + @spec resource_destroy() :: G2.result() + defdelegate resource_destroy, to: Nif, as: :destroy + + ############################################################################## + # Exposed Functions + ############################################################################## + + @doc """ + Adds a configuration JSON document to the Senzing database. + + See https://docs.senzing.com/python/3/g2configmgr/index.html#addconfig + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, config_json} = Senzing.G2.Config.save(config) + iex> {:ok, config_id} = Senzing.G2.ConfigManager.add_config(config_json, comment: "comment") + iex> is_integer(config_id) + true + + """ + @spec add_config(config :: Config.t(), opts :: [comment: String.t()]) :: G2.result(config_id()) + def add_config(config, opts \\ []), do: Nif.add_config(config, Keyword.get(opts, :comment, "")) + + @doc """ + Retrieves a specific configuration JSON document from the data repository. + + See https://docs.senzing.com/python/3/g2configmgr/index.html#getconfig + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, config_json} = Senzing.G2.Config.save(config) + iex> {:ok, config_id} = Senzing.G2.ConfigManager.add_config(config_json, comment: "comment") + iex> {:ok, config_json} = Senzing.G2.ConfigManager.get_config(config_id) + iex> is_binary(config_json) + true + + """ + @spec get_config(config_id :: config_id()) :: G2.result(Config.t()) + def get_config(config_id), do: Nif.get_config(config_id) + + @doc """ + Retrieves a list of the configuration JSON documents contained in the data repository. + + See https://docs.senzing.com/python/3/g2configmgr/index.html#listconfigs + + ## Examples + + iex> {:ok, configs} = Senzing.G2.ConfigManager.list_configs() + iex> # {:ok, [%{"CONFIG_COMMENTS" => "comment", "CONFIG_ID" => 1990907876, "SYS_CREATE_DT" => "2024-02-22 19:46:22.556"}]} + + """ + @spec list_configs() :: G2.result([config_parameters()]) + def list_configs do + with {:ok, json} <- Nif.list_configs(), + %{"CONFIGS" => configs} <- :json.decode(json), + do: {:ok, configs} + end + + @doc """ + Retrieves a specific configuration ID from the data repository. + + See https://docs.senzing.com/python/3/g2configmgr/index.html#getdefaultconfigid + + ## Examples + + iex> {:ok, default_config_id} = Senzing.G2.ConfigManager.get_default_config_id() + iex> is_integer(default_config_id) + true + + """ + @spec get_default_config_id() :: G2.result(config_id()) + defdelegate get_default_config_id, to: Nif + + @doc """ + Sets the default configuration JSON document in the data repository. + + See https://docs.senzing.com/python/3/g2configmgr/index.html#setdefaultconfigid + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, config_json} = Senzing.G2.Config.save(config) + iex> {:ok, config_id} = Senzing.G2.ConfigManager.add_config(config_json, comment: "comment") + iex> :ok = Senzing.G2.ConfigManager.set_default_config_id(config_id) + iex> {:ok, default_config_id} = Senzing.G2.ConfigManager.get_default_config_id() + iex> config_id == default_config_id + true + + """ + @spec set_default_config_id(config_id :: config_id()) :: G2.result() + defdelegate set_default_config_id(config_id), to: Nif + + @doc """ + Checks the current default configuration ID, and if it matches, replaces it with another configured ID in the database. + + See https://docs.senzing.com/python/3/g2configmgr/index.html#replacedefaultconfigid + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, config_json} = Senzing.G2.Config.save(config) + iex> {:ok, config_id} = Senzing.G2.ConfigManager.add_config(config_json, comment: "comment") + iex> :ok = Senzing.G2.ConfigManager.set_default_config_id(config_id) + iex> {:ok, new_config_id} = Senzing.G2.ConfigManager.add_config(config_json, comment: "comment") + iex> :ok = Senzing.G2.ConfigManager.replace_default_config_id(new_config_id, config_id) + iex> {:ok, default_config_id} = Senzing.G2.ConfigManager.get_default_config_id() + iex> new_config_id == default_config_id + true + + """ + @spec replace_default_config_id(new_config_id :: config_id(), old_config_id :: config_id()) :: G2.result() + defdelegate replace_default_config_id(new_config_id, old_config_id), to: Nif +end diff --git a/lib/senzing/g2/config_manager/nif.ex b/lib/senzing/g2/config_manager/nif.ex new file mode 100644 index 0000000..b2bac18 --- /dev/null +++ b/lib/senzing/g2/config_manager/nif.ex @@ -0,0 +1,128 @@ +defmodule Senzing.G2.ConfigManager.Nif do + @moduledoc false + + use Senzing.Nif + + ~z""" + const beam = @import("beam"); + const G2ConfigMgr = @cImport(@cInclude("libg2configmgr.h")); + + fn get_and_clear_last_exception(env: beam.env) !beam.term { + var slice = try beam.allocator.alloc(u8, 1024); + defer beam.allocator.free(slice); + + var size: usize = @intCast(G2ConfigMgr.G2ConfigMgr_getLastException(slice.ptr, 1024)); + + if (size == 0) { + return beam.make(env, .unknown_error, .{}); + } + + // Size contains zero byte + slice = try beam.allocator.realloc(slice, size - 1); + + var code = G2ConfigMgr.G2ConfigMgr_getLastExceptionCode(); + + G2ConfigMgr.G2ConfigMgr_clearLastException(); + + return beam.make(env, .{ code, slice }, .{}); + } + + fn resize_pointer(ptr: ?*anyopaque, size: usize) callconv(.C) ?*anyopaque { + _ = ptr; + const newPtr = beam.allocator.alloc([*c]u8, size) catch return null; + return @as(*anyopaque, @ptrCast(newPtr.ptr)); + } + + pub fn init(env: beam.env, name: []u8, ini_params: []u8, verbose_logging: bool) !beam.term { + var g2_name = try beam.allocator.dupeZ(u8, name); + var g2_ini_params = try beam.allocator.dupeZ(u8, ini_params); + var g2_verbose_logging: i8 = if(verbose_logging) 1 else 0; + + if (G2ConfigMgr.G2ConfigMgr_init(g2_name, g2_ini_params, g2_verbose_logging) != 0) { + var reason = try get_and_clear_last_exception(env); + _ = G2ConfigMgr.G2ConfigMgr_destroy(); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + + pub fn destroy(env: beam.env) !beam.term { + if(G2ConfigMgr.G2ConfigMgr_destroy() != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + return beam.make(env, .@"ok", .{}); + } + + pub fn add_config(env: beam.env, config: []u8, comment: []u8) !beam.term { + var g2_config = try beam.allocator.dupeZ(u8, config); + var g2_comment = try beam.allocator.dupeZ(u8, comment); + var config_id: c_longlong = 0; + + if (G2ConfigMgr.G2ConfigMgr_addConfig(g2_config, g2_comment, &config_id) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", config_id}, .{}); + } + + pub fn get_config(env: beam.env, config_id: c_longlong) !beam.term { + var bufSize: usize = 1024; + var initialResponseBuf = try beam.allocator.alloc(u8, bufSize); + defer beam.allocator.free(initialResponseBuf); + var responseBuf: [*c]u8 = initialResponseBuf.ptr; + + if (G2ConfigMgr.G2ConfigMgr_getConfig(config_id, &responseBuf, &bufSize, resize_pointer) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", responseBuf}, .{}); + } + + pub fn list_configs(env: beam.env) !beam.term { + var bufSize: usize = 1024; + var initialResponseBuf = try beam.allocator.alloc(u8, bufSize); + defer beam.allocator.free(initialResponseBuf); + var responseBuf: [*c]u8 = initialResponseBuf.ptr; + + if (G2ConfigMgr.G2ConfigMgr_getConfigList(&responseBuf, &bufSize, resize_pointer) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", responseBuf}, .{}); + } + + pub fn get_default_config_id(env: beam.env) !beam.term { + var config_id: c_longlong = 0; + + if (G2ConfigMgr.G2ConfigMgr_getDefaultConfigID(&config_id) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", config_id}, .{}); + } + + pub fn set_default_config_id(env: beam.env, config_id: c_longlong) !beam.term { + if (G2ConfigMgr.G2ConfigMgr_setDefaultConfigID(config_id) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + + pub fn replace_default_config_id(env: beam.env, new_config_id: c_longlong, old_config_id: c_longlong) !beam.term { + if (G2ConfigMgr.G2ConfigMgr_replaceDefaultConfigID(old_config_id, new_config_id) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + """ +end diff --git a/lib/senzing/g2/engine.ex b/lib/senzing/g2/engine.ex new file mode 100644 index 0000000..4962586 --- /dev/null +++ b/lib/senzing/g2/engine.ex @@ -0,0 +1,300 @@ +defmodule Senzing.G2.Engine do + @moduledoc """ + G2 Engine NIF Functionality + """ + + @behaviour Senzing.G2.ResourceInit + + alias Senzing.G2 + alias Senzing.G2.Config + alias Senzing.G2.ConfigManager + alias Senzing.G2.Engine.Nif + alias Senzing.G2.ResourceInit + + @type resource_init_option() :: + {:verbose_logging, boolean()} | {:prime, boolean()} | {:config_id, integer()} + @type resource_init_options() :: [resource_init_option()] + + @typedoc """ + Record as a map + + See https://senzing.zendesk.com/hc/en-us/articles/231925448-Generic-Entity-Specification-Data-Mapping + + ## Example + + ```json + { + "DATA_SOURCE": "COMPANIES", + "RECORD_ID": 2001, + "RECORD_TYPE": "ORGANIZATION", + "NAME_LIST": [ + { + "NAME_TYPE": "PRIMARY", + "NAME_ORG": "Presto Company" + } + ], + "TAX_ID_NUMBER": "11111", + "TAX_ID_COUNTRY": "US", + "ADDRESS_LIST": [ + { + "ADDR_TYPE": "PRIMARY", + "ADDR_LINE1": "Presto Plaza - 2001 Eastern Ave", + "ADDR_CITY": "Las Vegas", + "ADDR_STATE": "NV", + "ADDR_POSTAL_CODE": "89111", + "ADDR_COUNTRY": "US" + }, + { + "ADDR_TYPE": "MAIL", + "ADDR_LINE1": "Po Box 111", + "ADDR_CITY": "Las Vegas", + "ADDR_STATE": "NV", + "ADDR_POSTAL_CODE": "89111", + "ADDR_COUNTRY": "US" + } + ], + "PHONE_LIST": [ + { + "PHONE_TYPE": "PRIMARY", + "PHONE_NUMBER": "800-201-2001" + } + ], + "WEBSITE_ADDRESS": "Prestofabrics.com", + "SOCIAL_HANDLE": "@prestofabrics", + "SOCIAL_NETWORK": "twitter" + } + ``` + """ + @type record() :: map() + @type data_source() :: String.t() + + # This method will initialize the G2 processing object. + # + # It must be called once per process, prior to any other calls. + # + # Usually you will want to start the engine by starting the `senzing` + # application or by starting `Senzing.G2.Init` module as a worker. + @doc false + @impl ResourceInit + @spec resource_init( + name :: String.t(), + ini_params :: ResourceInit.ini_params(), + options :: resource_init_options() + ) :: G2.result() + def resource_init(name, config, options \\ []) when is_binary(name) and is_map(config) do + init = + case Keyword.fetch(options, :config_id) do + :error -> &Nif.init/3 + {:ok, config_id} -> &Nif.init_with_config_id(&1, &2, config_id, &3) + end + + with :ok <- + init.( + name, + IO.iodata_to_binary(:json.encode(config)), + options[:verbose_logging] || false + ) do + if options[:prime], do: prime(), else: :ok + end + end + + @doc """ + This method will re-initialize the G2 processing object. + + See https://docs.senzing.com/python/3/g2engine/init/#reinit + + ## Examples + + iex> {:ok, config} = Senzing.G2.Config.start_link([]) + iex> {:ok, config_json} = Senzing.G2.Config.save(config) + iex> {:ok, config_id} = Senzing.G2.ConfigManager.add_config(config_json, comment: "comment") + iex> Senzing.G2.Engine.reinit(config_id) + :ok + + """ + @doc type: :initialization + @spec reinit(config_id :: integer()) :: G2.result() + defdelegate reinit(config_ig), to: Nif + + @doc """ + This method may optionally be called to pre-initialize some of the heavier + weight internal resources of the G2 engine. + + See https://docs.senzing.com/python/3/g2engine/init/#primeengine + + ## Examples + + iex> Senzing.G2.Engine.prime() + :ok + + """ + @doc type: :initialization + @spec prime() :: G2.result() + defdelegate prime(), to: Nif + + @doc """ + This method returns an identifier for the loaded G2 engine configuration. + + See https://docs.senzing.com/python/3/g2engine/init/#getactiveconfigid + + ## Examples + + iex> {:ok, id} = Senzing.G2.Engine.get_active_config_id() + iex> is_integer(id) + true + + """ + @doc type: :initialization + @spec get_active_config_id() :: G2.result(ConfigManager.config_id()) + defdelegate get_active_config_id(), to: Nif + + @doc """ + This method will export the current configuration of the G2 engine. + + See https://docs.senzing.com/python/3/g2engine/init/#exportconfig + + ## Examples + + iex> {:ok, {config, config_id}} = Senzing.G2.Engine.export_config() + iex> is_binary(config) + true + iex> is_integer(config_id) + true + + """ + @doc type: :initialization + @spec export_config() :: G2.result({Config.t(), ConfigManager.config_id()}) + defdelegate export_config(), to: Nif + + @doc """ + This method returns the date of when the entity datastore was last modified. + + See https://docs.senzing.com/python/3/g2engine/init/#getrepositorylastmodified + + ## Examples + + iex> {:ok, %DateTime{}} = Senzing.G2.Engine.get_repository_last_modified() + iex> # {:ok, ~U[2024-04-02 11:23:14.613Z]} + + """ + @doc type: :initialization + @spec get_repository_last_modified() :: G2.result(DateTime.t()) + def get_repository_last_modified do + with {:ok, time} <- Nif.get_repository_last_modified(), + do: DateTime.from_unix(time, :millisecond) + end + + @doc """ + This method is used to add entity data into the system. + + This adds or updates a single entity observation record, by adding features + for the observation. + + See https://docs.senzing.com/python/3/g2engine/adding/ + + ## Options + + * `:load_id` - The load ID for the record. + * `:record_id` - The record ID for the record. Can be left out and will + automatically be detected based on record definition. + * `:return_info` - If `true`, the response will include information about the + changes made. `nil` in response otherwise. + * `:return_record_id` - If `true`, the response will include the record ID. + + ## Examples + + iex> {:ok, {record_id, _info}} = Senzing.G2.Engine.add_record( + ...> %{"RECORD_ID" => "test id"}, + ...> "TEST", + ...> load_id: "test load", + ...> record_id: "test id", + ...> return_info: true, + ...> return_record_id: true + ...> ) + iex> # info => %{ + ...> # "AFFECTED_ENTITIES" => [%{"ENTITY_ID" => 1}], + ...> # "DATA_SOURCE" => "TEST", + ...> # "INTERESTING_ENTITIES" => %{"ENTITIES" => []}, + ...> # "RECORD_ID" => "test id" + ...> # } + iex> record_id + "test id" + """ + @doc type: :add_records + @spec add_record( + record :: record(), + data_source :: data_source(), + opts :: [load_id: String.t(), return_info: boolean(), return_record_id: boolean(), record_id: String.t()] + ) :: G2.result({record_id :: String.t() | nil, info :: record() | nil}) | G2.result() + def add_record(record, data_source, opts \\ []) do + with {:ok, {record_id, info}} <- + Nif.add_record( + data_source, + opts[:record_id], + IO.iodata_to_binary(:json.encode(record)), + opts[:load_id], + opts[:return_record_id] || false, + opts[:return_info] || false + ) do + {:ok, {record_id, if(info, do: :json.decode(info))}} + end + end + + @doc """ + This method is used to replace entity data in the system. + + This replaces a single entity observation record, by replacing features for + the observation. + + See https://docs.senzing.com/python/3/g2engine/adding/ + + ## Options + + * `:load_id` - The load ID for the record. + * `:return_info` - If `true`, the response will include information about the + changes made. `nil` in response otherwise. + + ## Examples + + iex> {:ok, _info} = Senzing.G2.Engine.replace_record( + ...> %{"RECORD_ID" => "test id"}, + ...> "test id", + ...> "TEST", + ...> load_id: "test load", + ...> return_info: true + ...> ) + iex> # info => %{ + ...> # "AFFECTED_ENTITIES" => [%{"ENTITY_ID" => 1}], + ...> # "DATA_SOURCE" => "TEST", + ...> # "INTERESTING_ENTITIES" => %{"ENTITIES" => []}, + ...> # "RECORD_ID" => "test id" + ...> # } + """ + @doc type: :add_records + @spec replace_record( + record :: record(), + record_id :: String.t(), + data_source :: data_source(), + opts :: [load_id: String.t(), return_info: boolean()] + ) :: G2.result() + def replace_record(record, record_id, data_source, opts \\ []) do + with {:ok, info} <- + Nif.replace_record( + data_source, + record_id, + IO.iodata_to_binary(:json.encode(record)), + opts[:load_id], + opts[:return_info] || false + ) do + {:ok, :json.decode(info)} + end + end + + # This method will destroy and perform cleanup for the G2 processing object. + # + # It should be called after all other calls are complete. + @doc false + @impl ResourceInit + @spec resource_destroy() :: G2.result() + defdelegate resource_destroy, to: Nif, as: :destroy +end diff --git a/lib/senzing/g2/engine/nif.ex b/lib/senzing/g2/engine/nif.ex new file mode 100644 index 0000000..aaace3e --- /dev/null +++ b/lib/senzing/g2/engine/nif.ex @@ -0,0 +1,219 @@ +defmodule Senzing.G2.Engine.Nif do + @moduledoc false + + use Senzing.Nif + + ~z""" + const beam = @import("beam"); + const G2 = @cImport(@cInclude("libg2.h")); + const std = @import("std"); + + fn get_and_clear_last_exception(env: beam.env) !beam.term { + var slice = try beam.allocator.alloc(u8, 1024); + defer beam.allocator.free(slice); + + var size: usize = @intCast(G2.G2_getLastException(slice.ptr, 1024)); + + if (size == 0) { + return beam.make(env, .unknown_error, .{}); + } + + // Size contains zero byte + slice = try beam.allocator.realloc(slice, size - 1); + + var code = G2.G2_getLastExceptionCode(); + + G2.G2_clearLastException(); + + return beam.make(env, .{ code, slice }, .{}); + } + + fn resize_pointer(ptr: ?*anyopaque, size: usize) callconv(.C) ?*anyopaque { + _ = ptr; + const newPtr = beam.allocator.alloc([*c]u8, size) catch return null; + return @as(*anyopaque, @ptrCast(newPtr.ptr)); + } + + pub fn init(env: beam.env, name: []u8, ini_params: []u8, verbose_logging: bool) !beam.term { + var g2_name = try beam.allocator.dupeZ(u8, name); + var g2_ini_params = try beam.allocator.dupeZ(u8, ini_params); + var g2_verbose_logging: i8 = if(verbose_logging) 1 else 0; + + if (G2.G2_init(g2_name, g2_ini_params, g2_verbose_logging) != 0) { + var reason = try get_and_clear_last_exception(env); + _ = G2.G2_destroy(); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + + pub fn init_with_config_id(env: beam.env, name: []u8, ini_params: []u8, config_id: i64, verbose_logging: bool) !beam.term { + var g2_name = try beam.allocator.dupeZ(u8, name); + var g2_ini_params = try beam.allocator.dupeZ(u8, ini_params); + var g2_verbose_logging: i8 = if(verbose_logging) 1 else 0; + + if (G2.G2_initWithConfigID(g2_name, g2_ini_params, config_id, g2_verbose_logging) != 0) { + var reason = try get_and_clear_last_exception(env); + _ = G2.G2_destroy(); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + + pub fn reinit(env: beam.env, config_id: i64) !beam.term { + if (G2.G2_reinit(config_id) != 0) { + var reason = try get_and_clear_last_exception(env); + _ = G2.G2_destroy(); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + + pub fn prime(env: beam.env) !beam.term { + if (G2.G2_primeEngine() != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .@"ok", .{}); + } + + pub fn get_active_config_id(env: beam.env) !beam.term { + var config_id: c_longlong = 0; + if (G2.G2_getActiveConfigID(&config_id) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", config_id}, .{}); + } + + pub fn export_config(env: beam.env) !beam.term { + var config_id: c_longlong = 0; + + var bufSize: usize = 1024; + var initialResponseBuf = try beam.allocator.alloc(u8, bufSize); + defer beam.allocator.free(initialResponseBuf); + var responseBuf: [*c]u8 = initialResponseBuf.ptr; + + if (G2.G2_exportConfigAndConfigID(&responseBuf, &bufSize, resize_pointer, &config_id) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", .{responseBuf, config_id}}, .{}); + } + + pub fn get_repository_last_modified(env: beam.env) !beam.term { + var time: c_longlong = 0; + if (G2.G2_getRepositoryLastModifiedTime(&time) != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + return beam.make(env, .{.@"ok", time}, .{}); + } + + pub fn add_record(env: beam.env, dataSource: []u8, recordId: ?[]u8, record: []u8, loadId: ?[]u8, returnRecordId: bool, returnInfo: bool) !beam.term { + var g2_dataSource = try beam.allocator.dupeZ(u8, dataSource); + var g2_record = try beam.allocator.dupeZ(u8, record); + var g2_loadId = if (loadId) |id| try beam.allocator.dupeZ(u8, id) else try beam.allocator.dupeZ(u8, ""); + var g2_flags: c_longlong = 0; // Reserved for future use, not currently used + + var responseBuf: [*c]u8 = null; + var responseBufSize: usize = 1024; + var initialResponseBuf = try beam.allocator.alloc(u8, responseBufSize); + defer beam.allocator.free(initialResponseBuf); + responseBuf = initialResponseBuf.ptr; + + var recordIdBuf: [*c]u8 = null; + var recordIdBufSize: usize = 256; + var initialRecordIdBuf = try beam.allocator.alloc(u8, recordIdBufSize); + defer beam.allocator.free(initialRecordIdBuf); + recordIdBuf = initialRecordIdBuf.ptr; + + if (recordId) |id| { + recordIdBuf = try beam.allocator.dupeZ(u8, id); + } + + var success: c_longlong = -3; + + if (returnInfo) { + if (recordId) |id| { + var g2_recordId = try beam.allocator.dupeZ(u8, id); + success = G2.G2_addRecordWithInfo(g2_dataSource, g2_recordId, g2_record, g2_loadId, g2_flags, &responseBuf, &responseBufSize, resize_pointer); + } else { + success = G2.G2_addRecordWithInfoWithReturnedRecordID(g2_dataSource, g2_record, g2_loadId, g2_flags, recordIdBuf, recordIdBufSize, &responseBuf, &responseBufSize, resize_pointer); + } + } else { + if (recordId) |id| { + var g2_recordId = try beam.allocator.dupeZ(u8, id); + success = G2.G2_addRecord(g2_dataSource, g2_recordId, g2_record, g2_loadId); + } else { + success = G2.G2_addRecordWithReturnedRecordID(g2_dataSource, g2_record, g2_loadId, recordIdBuf, recordIdBufSize); + } + } + + if (success != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + if (returnInfo and returnRecordId) { + return beam.make(env, .{ .ok, .{ recordIdBuf, responseBuf } }, .{}); + } + if (returnInfo) { + return beam.make(env, .{ .ok, .{ .nil, responseBuf } }, .{}); + } + if (returnRecordId) { + return beam.make(env, .{ .ok, .{ recordIdBuf, .nil } }, .{}); + } + + return beam.make(env, .ok, .{}); + } + + pub fn replace_record(env: beam.env, dataSource: []u8, recordId: []u8, record: []u8, loadId: ?[]u8, returnInfo: bool) !beam.term { + var g2_dataSource = try beam.allocator.dupeZ(u8, dataSource); + var g2_recordId = try beam.allocator.dupeZ(u8, recordId); + var g2_record = try beam.allocator.dupeZ(u8, record); + var g2_loadId = if (loadId) |id| try beam.allocator.dupeZ(u8, id) else try beam.allocator.dupeZ(u8, ""); + var g2_flags: c_longlong = 0; // Reserved for future use, not currently used + + var responseBuf: [*c]u8 = null; + var responseBufSize: usize = 1024; + var initialResponseBuf = try beam.allocator.alloc(u8, responseBufSize); + defer beam.allocator.free(initialResponseBuf); + responseBuf = initialResponseBuf.ptr; + + var success: c_longlong = -3; + + if (returnInfo) { + success = G2.G2_replaceRecordWithInfo(g2_dataSource, g2_recordId, g2_record, g2_loadId, g2_flags, &responseBuf, &responseBufSize, resize_pointer); + } else { + success = G2.G2_replaceRecord(g2_dataSource, g2_recordId, g2_record, g2_loadId); + } + + if (success != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + + if (returnInfo) { + return beam.make(env, .{ .ok, responseBuf }, .{}); + } + + return beam.make(env, .ok, .{}); + } + + pub fn destroy(env: beam.env) !beam.term { + if(G2.G2_destroy() != 0) { + var reason = try get_and_clear_last_exception(env); + return beam.make_error_pair(env, reason, .{}); + } + return beam.make(env, .@"ok", .{}); + } + """ +end diff --git a/lib/senzing/g2/resource_init.ex b/lib/senzing/g2/resource_init.ex new file mode 100644 index 0000000..41aaa15 --- /dev/null +++ b/lib/senzing/g2/resource_init.ex @@ -0,0 +1,115 @@ +defmodule Senzing.G2.ResourceInit do + @moduledoc """ + Senzing Context Worker + + Starts and cleans up resources needed by the Senzing Engine. + + ## Contexts + + * `Senzing.G2.Config` + * `Senzing.G2.ConfigManager` + * `Senzing.G2.Engine` + + ## Usage + + Via GenServer: + + ```elixir + {:ok, pid} = Senzing.G2.ResourceInit.start_link( + mod: Senzing.G2.Engine + ) + ``` + + The resource can also be automatically started by setting `mod` in the + `senzing` dependency configuration. + + """ + use GenServer + + alias Senzing.G2 + alias Senzing.G2.Config + alias Senzing.G2.ConfigManager + alias Senzing.G2.Engine + + require Logger + + @type ini_params() :: map() + + @type option() :: + {:mod, Config | Engine} + | {:name, String.t()} + | {:ini_params, ini_params()} + | Engine.resource_init_option() + | Config.resource_init_option() + | ConfigManager.resource_init_option() + @type options() :: [option()] + + @doc false + @spec start_link(opts :: options()) :: GenServer.on_start() + def start_link(opts), + do: GenServer.start_link(__MODULE__, opts, name: Module.concat(__MODULE__, Keyword.fetch!(opts, :mod))) + + @doc false + @impl GenServer + def init(options) do + options = Keyword.put_new_lazy(options, :ini_params, &default_ini_params/0) + + {mod, options} = Keyword.pop!(options, :mod) + {name, options} = Keyword.pop(options, :name, "Senzing Engine") + {ini_params, options} = Keyword.pop!(options, :ini_params) + + Process.flag(:trap_exit, true) + + Logger.info("Starting #{inspect(mod)} with name #{inspect(name)}") + + case mod.resource_init(name, ini_params, options) do + :ok -> {:ok, mod} + {:error, reason} -> {:stop, reason} + end + end + + @doc false + @spec child_spec(init_arg :: options()) :: Supervisor.child_spec() + def child_spec(init_arg), do: Map.put(super(init_arg), :id, Module.concat(__MODULE__, Keyword.fetch!(init_arg, :mod))) + + @doc false + @impl GenServer + def terminate(_reason, mod) do + Logger.info("Stopping #{inspect(mod)}") + + mod.resource_destroy() + end + + @doc false + @callback resource_init(name :: String.t(), ini_params :: ini_params(), options :: Keyword.t()) :: + G2.result() + @doc false + @callback resource_destroy() :: G2.result() + + defp default_ini_params do + root_path = Senzing.locate_root_path() + + config_path = Application.get_env(:senzing, :config_path, Path.join(root_path, "etc")) + + resource_path = + Application.get_env(:senzing, :resource_path, Path.join(root_path, "resources")) + + support_path = Application.get_env(:senzing, :support_path, Path.join(root_path, "data")) + + db_connection = + Application.get_env( + :senzing, + :db_connection, + "sqlite3://#{Path.join(root_path, "var/sqlite/G2C.db")}" + ) + + %{ + PIPELINE: %{ + CONFIGPATH: config_path, + RESOURCEPATH: resource_path, + SUPPORTPATH: support_path + }, + SQL: %{CONNECTION: db_connection} + } + end +end diff --git a/lib/senzing/nif.ex b/lib/senzing/nif.ex new file mode 100644 index 0000000..5a31d7c --- /dev/null +++ b/lib/senzing/nif.ex @@ -0,0 +1,18 @@ +defmodule Senzing.Nif do + @moduledoc false + alias Senzing.G2 + + defmacro __using__(opts \\ []) do + opts = + Keyword.merge(opts, + otp_app: :senzing, + include_dir: [G2.locate_sdk_path()], + link_lib: [dbg(Path.join(G2.locate_lib_path(), "libG2.so"))], + local_zig: true + ) + + quote do + use Zig, unquote(opts) + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..763af82 --- /dev/null +++ b/mix.exs @@ -0,0 +1,92 @@ +defmodule Senzing.MixProject do + use Mix.Project + + def project do + [ + app: :senzing, + version: "0.0.0-dev", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps(), + docs: &docs/0, + source_url: "https://github.com/sustema-ag/senzing-elixir", + description: "Elixir NIF for Senzing Entity Matching", + package: package(), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + "coveralls.github": :test, + "coveralls.multiple": :test + ] + ] + end + + def application do + [ + extra_applications: [:logger], + mod: application_mod(Mix.env()) + ] + end + + defp application_mod(env) + defp application_mod(:test), do: {Senzing.Application, []} + defp application_mod(_env), do: {Senzing.Application, mod: Senzing.G2.Engine} + + defp package do + [ + maintainers: ["Jonatan Männchen"], + files: [ + "lib", + "LICENSE*", + "mix.exs", + "README*" + ], + licenses: ["MIT"], + links: %{"Github" => "https://github.com/sustema-ag/senzing-elixir"} + ] + end + + defp docs do + # TODO: Re-enable + # {ref, 0} = System.cmd("git", ["rev-parse", "--verify", "--quiet", "HEAD"]) + + [ + # source_ref: ref, + main: "Senzing", + logo: "assets/senzing-logo-small.png", + assets: %{"assets" => "assets"}, + groups_for_docs: [ + # Engine + "Functions: Initialization": &(&1[:type] == :initialization), + "Functions: Add Records": &(&1[:type] == :add_records), + "Functions: Reevaluating": &(&1[:type] == :reevaluating), + "Functions: Redo Processing": &(&1[:type] == :redo_processing), + "Functions: Deleting Records": &(&1[:type] == :deleting_records), + "Functions: Getting Entities and Records": &(&1[:type] == :getting_entities_and_records), + "Functions: Searching for Entities": &(&1[:type] == :searching_for_entities), + "Functions: Finding Paths": &(&1[:type] == :finding_paths), + "Functions: Finding Networks": &(&1[:type] == :finding_networks), + "Functions: Why": &(&1[:type] == :why), + "Functions: How": &(&1[:type] == :how), + "Functions: Reporting": &(&1[:type] == :reporting), + "Functions: Cleanup": &(&1[:type] == :cleanup), + "Functions: Statistics": &(&1[:type] == :statistics) + ] + ] + end + + defp deps do + [ + {:credo, "~> 1.7", only: :dev, runtime: false}, + {:dialyxir, "~> 1.4", only: :dev, runtime: false}, + {:excoveralls, "~> 0.18", only: :test}, + {:ex_doc, "~> 0.34.0", only: :dev, runtime: false}, + {:makeup_json, "~> 0.1", only: :dev, runtime: false}, + {:styler, "~> 0.11.9", runtime: false, only: :dev}, + {:zigler, "~> 0.11.1"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..8642a68 --- /dev/null +++ b/mix.lock @@ -0,0 +1,22 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, + "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, + "excoveralls": {:hex, :excoveralls, "0.18.1", "a6f547570c6b24ec13f122a5634833a063aec49218f6fff27de9df693a15588c", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d65f79db146bb20399f23046015974de0079668b9abb2f5aac074d078da60b8d"}, + "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"}, + "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, + "makeup_json": {:hex, :makeup_json, "0.1.1", "44204f3f023ff3daca682cc0b1dc372098514460064599979cb4cde5926cff70", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "3879d78117e37a9b1e567b9cc76c1b5b51b9efc5f4f4301ea5e53fb70c59c718"}, + "minisign": {:hex, :minisign, "0.1.1", "1f8812f8a1257c1aacd1151526a0c841fc669976c3d1609056df32c75134b891", [:mix], [], "hexpm", "0fc40e18099f660ad9f79f589ecd9d50f5a2efc5f985e8debc04ed3a9c33c25a"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "pegasus": {:hex, :pegasus, "0.2.4", "3d8d5a2c89552face9c7ca14f959cc6c6d2cd645db1df85940db4c77c3b21a24", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2d21e2b6b946fe3cd441544bf9856e7772f29050332f0255e166a13cdbe65bb4"}, + "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, + "zig_get": {:hex, :zig_get, "0.11.3", "d4406a06a42a1ede92e9746e1e1579e43a3607c76baf6edd106772b828c6eca6", [:mix], [{:minisign, "~> 0.1", [hex: :minisign, repo: "hexpm", optional: false]}], "hexpm", "36214404024f94ee864c587d52173f9d386eb92ec7df0a8b4082071a5ca8ec6a"}, + "zig_parser": {:hex, :zig_parser, "0.3.0", "2a597ae6990447e70e46691d9ca7073afb3c3e13ab143dce1df0865a764c48f4", [:mix], [{:pegasus, "~> 0.2.4", [hex: :pegasus, repo: "hexpm", optional: false]}], "hexpm", "6b90ee53cf23e53824dbdad2d2ca597a1cb8d882b0748638921bddda1563a736"}, + "zigler": {:hex, :zigler, "0.11.1", "b0a9af16e811f3fbc5d1c9ac6d75bf882a5c50bf3fb971ef91ad383ca91fe642", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:zig_get, "~> 0.11.1", [hex: :zig_get, repo: "hexpm", optional: false]}, {:zig_parser, "~> 0.3.0", [hex: :zig_parser, repo: "hexpm", optional: false]}], "hexpm", "f496e634c5533082fec3a1955873cd7dc5a46ff46c96a3fa6f0cfb3c9cd25077"}, +} diff --git a/test/senzing/g2/config_manager_test.exs b/test/senzing/g2/config_manager_test.exs new file mode 100644 index 0000000..26d7f5a --- /dev/null +++ b/test/senzing/g2/config_manager_test.exs @@ -0,0 +1,87 @@ +defmodule Senzing.G2.ConfigManagerTest do + use ExUnit.Case, async: false + + alias Senzing.G2.Config + alias Senzing.G2.ConfigManager + alias Senzing.G2.ResourceInit + + doctest Config + + setup_all do + start_supervised!({ResourceInit, mod: Config}) + start_supervised!({ResourceInit, mod: ConfigManager}) + + config = start_supervised!(Config) + {:ok, config_json} = Config.save(config) + + {:ok, config: config_json} + end + + describe inspect(&ConfigManager.add_config/0) do + test "works", %{config: config} do + assert {:ok, config_id} = ConfigManager.add_config(config, comment: "foo") + assert is_integer(config_id) + end + end + + describe inspect(&ConfigManager.get_config/1) do + test "works", %{config: config} do + assert {:ok, config_id} = ConfigManager.add_config(config) + assert {:ok, config_json} = ConfigManager.get_config(config_id) + assert is_binary(config_json) + end + end + + describe inspect(&ConfigManager.list_configs/0) do + test "works", %{config: config} do + assert {:ok, config_id} = ConfigManager.add_config(config) + assert {:ok, configs} = ConfigManager.list_configs() + assert Enum.any?(configs, &match?(%{"CONFIG_ID" => ^config_id}, &1)) + end + end + + describe inspect(&ConfigManager.get_default_config_id/0) do + test "works" do + assert {:ok, default_config_id} = ConfigManager.get_default_config_id() + assert is_integer(default_config_id) + end + end + + describe inspect(&ConfigManager.set_default_config_id/1) do + test "works", %{config: config} do + assert {:ok, config_id} = ConfigManager.add_config(config) + + assert :ok = ConfigManager.set_default_config_id(config_id) + assert {:ok, ^config_id} = ConfigManager.get_default_config_id() + end + + test "errors with invalid id" do + assert {:error, {7221, "7221E|No engine configuration registered with data ID [7]."}} = + ConfigManager.set_default_config_id(7) + end + end + + describe inspect(&ConfigManager.replace_default_config_id/2) do + test "works", %{config: config} do + assert {:ok, config_id} = ConfigManager.add_config(config) + assert :ok = ConfigManager.set_default_config_id(config_id) + + assert :ok = ConfigManager.replace_default_config_id(config_id, config_id) + end + + test "errors with invalid old id", %{config: config} do + assert {:ok, config_id} = ConfigManager.add_config(config) + + assert {:error, {7245, "7245E|Current configuration ID does not match specified data ID [7]."}} = + ConfigManager.replace_default_config_id(config_id, 7) + end + + test "errors with invalid new id", %{config: config} do + assert {:ok, config_id} = ConfigManager.add_config(config) + assert :ok = ConfigManager.set_default_config_id(config_id) + + assert {:error, {7221, "7221E|No engine configuration registered with data ID [7]."}} = + ConfigManager.replace_default_config_id(7, config_id) + end + end +end diff --git a/test/senzing/g2/config_test.exs b/test/senzing/g2/config_test.exs new file mode 100644 index 0000000..051903c --- /dev/null +++ b/test/senzing/g2/config_test.exs @@ -0,0 +1,85 @@ +defmodule Senzing.G2.ConfigTest do + use ExUnit.Case, async: false + + alias Senzing.G2.Config + alias Senzing.G2.ResourceInit + + doctest Config + + setup_all do + start_supervised!({ResourceInit, mod: Config}) + + :ok + end + + describe inspect(&Config.create/0) do + test "works" do + config = start_supervised!(Config) + + assert {:ok, data_sources} = Config.list_data_sources(config) + + assert ~w[TEST SEARCH] == Enum.map(data_sources, & &1["DSRC_CODE"]) + end + end + + describe inspect(&Config.load/1) do + test "works" do + {:ok, config} = Config.start_link([]) + assert {:ok, json} = Config.save(config) + GenServer.stop(config, :normal) + + new_config = start_supervised!({Config, load: json}) + assert {:ok, ^json} = Config.save(new_config) + end + end + + describe inspect(&Config.list_data_sources/1) do + test "works" do + config = start_supervised!(Config) + + assert {:ok, data_sources} = Config.list_data_sources(config) + + assert ~w[TEST SEARCH] == Enum.map(data_sources, & &1["DSRC_CODE"]) + end + end + + describe inspect(&Config.add_data_source/2) do + test "works" do + config = start_supervised!(Config) + + assert {:ok, %{"DSRC_ID" => ds_id}} = + Config.add_data_source(config, %{"DSRC_CODE" => "NAME_OF_DATASOURCE"}) + + assert {:ok, + [ + %{"DSRC_CODE" => "TEST", "DSRC_ID" => 1}, + %{"DSRC_CODE" => "SEARCH", "DSRC_ID" => 2}, + %{"DSRC_CODE" => "NAME_OF_DATASOURCE", "DSRC_ID" => ^ds_id} + ]} = Config.list_data_sources(config) + end + end + + describe inspect(&Config.delete_data_source/2) do + test "works" do + config = start_supervised!(Config) + + assert {:ok, %{"DSRC_ID" => ds_id}} = + Config.add_data_source(config, %{"DSRC_CODE" => "NAME_OF_DATASOURCE"}) + + assert {:ok, + [ + %{"DSRC_CODE" => "TEST", "DSRC_ID" => 1}, + %{"DSRC_CODE" => "SEARCH", "DSRC_ID" => 2}, + %{"DSRC_CODE" => "NAME_OF_DATASOURCE", "DSRC_ID" => ^ds_id} + ]} = Config.list_data_sources(config) + + assert :ok = Config.delete_data_source(config, %{"DSRC_CODE" => "NAME_OF_DATASOURCE"}) + + assert {:ok, + [ + %{"DSRC_CODE" => "TEST", "DSRC_ID" => 1}, + %{"DSRC_CODE" => "SEARCH", "DSRC_ID" => 2} + ]} = Config.list_data_sources(config) + end + end +end diff --git a/test/senzing/g2/engine_test.exs b/test/senzing/g2/engine_test.exs new file mode 100644 index 0000000..52058e2 --- /dev/null +++ b/test/senzing/g2/engine_test.exs @@ -0,0 +1,147 @@ +defmodule Senzing.G2.EngineTest do + use ExUnit.Case, async: false + + alias Senzing.G2.Config + alias Senzing.G2.ConfigManager + alias Senzing.G2.Engine + alias Senzing.G2.ResourceInit + + doctest Engine, except: [prime: 0] + doctest Engine, only: [prime: 0], tags: [:slow] + + setup_all do + start_supervised!({ResourceInit, mod: Config}) + start_supervised!({ResourceInit, mod: ConfigManager}) + start_supervised!({ResourceInit, mod: Engine}) + + :ok + end + + test "works" do + end + + describe inspect(&Engine.prime/0) do + @tag :slow + test "works" do + assert :ok = Engine.prime() + end + end + + describe inspect(&Engine.reinit/1) do + test "works" do + config_pid = start_supervised!(Config) + {:ok, config} = Config.save(config_pid) + + {:ok, config_id} = ConfigManager.add_config(config) + + assert :ok = Engine.reinit(config_id) + end + end + + describe inspect(&Engine.get_active_config_id/0) do + test "works" do + assert {:ok, default_config_id} = ConfigManager.get_default_config_id() + assert {:ok, ^default_config_id} = Engine.get_active_config_id() + end + end + + describe inspect(&Engine.export_config/0) do + test "works" do + assert {:ok, {config, config_id}} = Engine.export_config() + assert is_binary(config) + assert is_integer(config_id) + end + end + + describe inspect(&Engine.get_repository_last_modified/0) do + test "works" do + assert {:ok, %DateTime{}} = Engine.get_repository_last_modified() + end + end + + describe inspect(&Engine.add_record/4) do + test "works", %{test: test} do + id = "#{inspect(__MODULE__)}.#{inspect(test)}" + + assert :ok = + Engine.add_record(%{"RECORD_ID" => id}, "TEST", + load_id: id, + record_id: id + ) + + assert :ok = + Engine.add_record(%{"RECORD_ID" => id}, "TEST", load_id: id) + end + + test "returns info and id", %{test: test} do + id = "#{inspect(__MODULE__)}.#{inspect(test)}" + + assert {:ok, {^id, %{"RECORD_ID" => ^id}}} = + Engine.add_record(%{"RECORD_ID" => id}, "TEST", + load_id: id, + record_id: id, + return_info: true, + return_record_id: true + ) + + assert {:ok, {^id, %{"RECORD_ID" => ^id}}} = + Engine.add_record(%{"RECORD_ID" => id}, "TEST", + load_id: id, + return_info: true, + return_record_id: true + ) + end + + test "returns info", %{test: test} do + id = "#{inspect(__MODULE__)}.#{inspect(test)}" + + assert {:ok, {nil, %{"RECORD_ID" => ^id}}} = + Engine.add_record(%{"RECORD_ID" => id}, "TEST", + load_id: id, + record_id: id, + return_info: true + ) + + assert {:ok, {nil, %{"RECORD_ID" => ^id}}} = + Engine.add_record(%{"RECORD_ID" => id}, "TEST", + load_id: id, + return_info: true + ) + end + + test "returns id", %{test: test} do + id = "#{inspect(__MODULE__)}.#{inspect(test)}" + + assert {:ok, {^id, nil}} = + Engine.add_record(%{"RECORD_ID" => id}, "TEST", + load_id: id, + record_id: id, + return_record_id: true + ) + + assert {:ok, {^id, nil}} = + Engine.add_record(%{"RECORD_ID" => id}, "TEST", + load_id: id, + return_record_id: true + ) + end + end + + describe inspect(&Engine.replace_record/4) do + test "works", %{test: test} do + id = "#{inspect(__MODULE__)}.#{inspect(test)}" + + assert :ok = Engine.replace_record(%{"RECORD_ID" => id}, id, "TEST") + end + + test "returns info", %{test: test} do + id = "#{inspect(__MODULE__)}.#{inspect(test)}" + + assert {:ok, %{"RECORD_ID" => ^id}} = + Engine.replace_record(%{"RECORD_ID" => id}, id, "TEST", + load_id: id, + return_info: true + ) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..6a0af57 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start(capture_log: true)