├── .gitignore
├── pint.json
├── .github
├── workflows
│ ├── linter.yml
│ ├── codeql-analysis.yml
│ └── tests.yml
└── ISSUE_TEMPLATE
│ ├── documentation.yaml
│ ├── feature.yaml
│ └── bug.yaml
├── phpunit.xml
├── Dockerfile
├── LICENSE.md
├── composer.json
├── tests
└── Audit
│ ├── Adapter
│ ├── DatabaseTest.php
│ └── ClickHouseTest.php
│ └── AuditBase.php
├── docker-compose.yml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── src
└── Audit
│ ├── Log.php
│ ├── Adapter.php
│ ├── Adapter
│ ├── SQL.php
│ ├── Database.php
│ └── ClickHouse.php
│ └── Audit.php
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | /.idea/
--------------------------------------------------------------------------------
/pint.json:
--------------------------------------------------------------------------------
1 | {
2 | "preset": "psr12"
3 | }
--------------------------------------------------------------------------------
/.github/workflows/linter.yml:
--------------------------------------------------------------------------------
1 | name: "Linter"
2 |
3 | on: [ pull_request ]
4 | jobs:
5 | lint:
6 | name: Linter
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - name: Checkout repository
11 | uses: actions/checkout@v3
12 | with:
13 | fetch-depth: 2
14 |
15 | - run: git checkout HEAD^2
16 |
17 | - name: Run Linter
18 | run: |
19 | docker run --rm -v $PWD:/app composer sh -c \
20 | "composer install --profile --ignore-platform-reqs && composer lint"
21 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on: [ pull_request ]
4 | jobs:
5 | lint:
6 | name: CodeQL
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - name: Checkout repository
11 | uses: actions/checkout@v3
12 | with:
13 | fetch-depth: 2
14 |
15 | - run: git checkout HEAD^2
16 |
17 | - name: Run CodeQL
18 | run: |
19 | docker run --rm -v $PWD:/app composer sh -c \
20 | "composer install --profile --ignore-platform-reqs && composer check"
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 | ./tests/
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: "Tests"
2 |
3 | on: [ pull_request ]
4 | jobs:
5 | lint:
6 | name: Tests
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - name: Checkout repository
11 | uses: actions/checkout@v3
12 | with:
13 | fetch-depth: 2
14 |
15 | - run: git checkout HEAD^2
16 |
17 | - name: Build
18 | run: |
19 | docker compose build
20 | docker compose up -d --wait
21 |
22 | - name: Run Tests
23 | run: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM composer:2.8 AS step0
2 |
3 | WORKDIR /src/
4 |
5 | COPY composer.lock /src/
6 | COPY composer.json /src/
7 |
8 | RUN composer install --ignore-platform-reqs --optimize-autoloader \
9 | --no-plugins --no-scripts --prefer-dist
10 |
11 | FROM php:8.3.3-cli-alpine3.19 AS final
12 |
13 | LABEL maintainer="team@appwrite.io"
14 |
15 | RUN docker-php-ext-install pdo_mysql
16 |
17 | WORKDIR /code
18 |
19 | COPY --from=step0 /src/vendor /code/vendor
20 |
21 | # Add Source Code
22 | COPY ./tests /code/tests
23 | COPY ./src /code/src
24 | COPY ./phpunit.xml /code/phpunit.xml
25 |
26 | CMD [ "tail", "-f", "/dev/null" ]
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 Eldad Fux
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "utopia-php/audit",
3 | "description": "A simple audit library to manage application users logs",
4 | "type": "library",
5 | "keywords": [
6 | "php",
7 | "framework",
8 | "upf",
9 | "utopia",
10 | "audit"
11 | ],
12 | "license": "MIT",
13 | "minimum-stability": "stable",
14 | "scripts": {
15 | "lint": "./vendor/bin/pint --test",
16 | "format": "./vendor/bin/pint",
17 | "check": "./vendor/bin/phpstan analyse --level max src tests"
18 | },
19 | "autoload": {
20 | "psr-4": {
21 | "Utopia\\Audit\\": "src/Audit"
22 | }
23 | },
24 | "autoload-dev": {
25 | "psr-4": {
26 | "Utopia\\Tests\\": "tests"
27 | }
28 | },
29 | "require": {
30 | "php": ">=8.0",
31 | "utopia-php/database": "4.*",
32 | "utopia-php/fetch": "^0.4.2",
33 | "utopia-php/validators": "^0.1.0"
34 | },
35 | "require-dev": {
36 | "phpunit/phpunit": "9.*",
37 | "phpstan/phpstan": "1.*",
38 | "laravel/pint": "1.*"
39 | },
40 | "config": {
41 | "allow-plugins": {
42 | "php-http/discovery": true,
43 | "tbachert/spi": true
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/documentation.yaml:
--------------------------------------------------------------------------------
1 | name: "📚 Documentation"
2 | description: "Report an issue related to documentation"
3 | title: "📚 Documentation: "
4 | labels: [documentation]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to make our documentation better 🙏
10 | - type: textarea
11 | id: issue-description
12 | validations:
13 | required: true
14 | attributes:
15 | label: "💭 Description"
16 | description: "A clear and concise description of what the issue is."
17 | placeholder: "Documentation should not ..."
18 | - type: checkboxes
19 | id: no-duplicate-issues
20 | attributes:
21 | label: "👀 Have you spent some time to check if this issue has been raised before?"
22 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?"
23 | options:
24 | - label: "I checked and didn't find similar issue"
25 | required: true
26 | - type: checkboxes
27 | id: read-code-of-conduct
28 | attributes:
29 | label: "🏢 Have you read the Code of Conduct?"
30 | options:
31 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)"
32 | required: true
33 |
--------------------------------------------------------------------------------
/tests/Audit/Adapter/DatabaseTest.php:
--------------------------------------------------------------------------------
1 | setDatabase('utopiaTests');
33 | $database->setNamespace('namespace');
34 |
35 | $adapter = new Adapter\Database($database);
36 | $this->audit = new Audit($adapter);
37 | if (! $database->exists('utopiaTests')) {
38 | $database->create();
39 | $this->audit->setup();
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature.yaml:
--------------------------------------------------------------------------------
1 | name: 🚀 Feature
2 | description: "Submit a proposal for a new feature"
3 | title: "🚀 Feature: "
4 | labels: [feature]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out our feature request form 🙏
10 | - type: textarea
11 | id: feature-description
12 | validations:
13 | required: true
14 | attributes:
15 | label: "🔖 Feature description"
16 | description: "A clear and concise description of what the feature is."
17 | placeholder: "You should add ..."
18 | - type: textarea
19 | id: pitch
20 | validations:
21 | required: true
22 | attributes:
23 | label: "🎤 Pitch"
24 | description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable."
25 | placeholder: "In my use-case, ..."
26 | - type: checkboxes
27 | id: no-duplicate-issues
28 | attributes:
29 | label: "👀 Have you spent some time to check if this issue has been raised before?"
30 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?"
31 | options:
32 | - label: "I checked and didn't find similar issue"
33 | required: true
34 | - type: checkboxes
35 | id: read-code-of-conduct
36 | attributes:
37 | label: "🏢 Have you read the Code of Conduct?"
38 | options:
39 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)"
40 | required: true
41 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | mariadb:
3 | image: mariadb:10.11
4 | environment:
5 | - MYSQL_ROOT_PASSWORD=password
6 | networks:
7 | - abuse
8 | ports:
9 | - "9306:3306"
10 | healthcheck:
11 | test: ["CMD", "sh", "-c", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"]
12 | interval: 5s
13 | timeout: 3s
14 | retries: 10
15 | start_period: 30s
16 |
17 | clickhouse:
18 | image: clickhouse/clickhouse-server:25.11-alpine
19 | environment:
20 | - CLICKHOUSE_DB=default
21 | - CLICKHOUSE_USER=default
22 | - CLICKHOUSE_PASSWORD=clickhouse
23 | - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
24 | networks:
25 | - abuse
26 | ports:
27 | - "8123:8123"
28 | - "9000:9000"
29 | healthcheck:
30 | test: ["CMD", "clickhouse-client", "--host=localhost", "--port=9000", "--query=SELECT 1", "--format=Null"]
31 | interval: 5s
32 | timeout: 3s
33 | retries: 10
34 | start_period: 15s
35 |
36 | tests:
37 | build:
38 | context: .
39 | dockerfile: ./Dockerfile
40 | networks:
41 | - abuse
42 | depends_on:
43 | mariadb:
44 | condition: service_healthy
45 | clickhouse:
46 | condition: service_healthy
47 | volumes:
48 | - ./phpunit.xml:/code/phpunit.xml
49 | - ./src:/code/src
50 | - ./tests:/code/tests
51 | healthcheck:
52 | test: ["CMD", "php", "--version"]
53 | interval: 5s
54 | timeout: 3s
55 | retries: 3
56 | start_period: 5s
57 |
58 | networks:
59 | abuse:
60 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yaml:
--------------------------------------------------------------------------------
1 | name: "🐛 Bug Report"
2 | description: "Submit a bug report to help us improve"
3 | title: "🐛 Bug Report: "
4 | labels: [bug]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out our bug report form 🙏
10 | - type: textarea
11 | id: steps-to-reproduce
12 | validations:
13 | required: true
14 | attributes:
15 | label: "👟 Reproduction steps"
16 | description: "How do you trigger this bug? Please walk us through it step by step."
17 | placeholder: "When I ..."
18 | - type: textarea
19 | id: expected-behavior
20 | validations:
21 | required: true
22 | attributes:
23 | label: "👍 Expected behavior"
24 | description: "What did you think would happen?"
25 | placeholder: "It should ..."
26 | - type: textarea
27 | id: actual-behavior
28 | validations:
29 | required: true
30 | attributes:
31 | label: "👎 Actual Behavior"
32 | description: "What did actually happen? Add screenshots, if applicable."
33 | placeholder: "It actually ..."
34 | - type: dropdown
35 | id: utopia-version
36 | attributes:
37 | label: "🎲 Utopia Audit version"
38 | description: "What version of Utopia Audit are you running?"
39 | options:
40 | - Version 0.6.x
41 | - Version 0.5.x
42 | - Version 0.4.x
43 | - Version 0.3.x
44 | - Version 0.2.x
45 | - Version 0.1.x
46 | - Different version (specify in environment)
47 | validations:
48 | required: true
49 | - type: dropdown
50 | id: operating-system
51 | attributes:
52 | label: "💻 Operating system"
53 | description: "What OS is your server / device running on?"
54 | options:
55 | - Linux
56 | - MacOS
57 | - Windows
58 | - Something else
59 | validations:
60 | required: true
61 | - type: textarea
62 | id: enviromnemt
63 | validations:
64 | required: false
65 | attributes:
66 | label: "🧱 Your Environment"
67 | description: "Is your environment customized in any way?"
68 | placeholder: "I use Cloudflare for ..."
69 | - type: checkboxes
70 | id: no-duplicate-issues
71 | attributes:
72 | label: "👀 Have you spent some time to check if this issue has been raised before?"
73 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?"
74 | options:
75 | - label: "I checked and didn't find similar issue"
76 | required: true
77 | - type: checkboxes
78 | id: read-code-of-conduct
79 | attributes:
80 | label: "🏢 Have you read the Code of Conduct?"
81 | options:
82 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)"
83 | required: true
84 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to make participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity, expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at team@appwrite.io. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | We would ❤️ for you to contribute to Utopia-php and help make it better! We want contributing to Utopia-php to be fun, enjoyable, and educational for anyone and everyone. All contributions are welcome, including issues, new docs as well as updates and tweaks, blog posts, workshops, and more.
4 |
5 | ## How to Start?
6 |
7 | If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you!
8 |
9 | ## Code of Conduct
10 |
11 | Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](https://github.com/appwrite/appwrite/blob/master/CODE_OF_CONDUCT.md).
12 |
13 | ## Submit a Pull Request 🚀
14 |
15 | Branch naming convention is as following
16 |
17 | `TYPE-ISSUE_ID-DESCRIPTION`
18 |
19 | example:
20 |
21 | ```
22 | doc-548-submit-a-pull-request-section-to-contribution-guide
23 | ```
24 |
25 | When `TYPE` can be:
26 |
27 | - **feat** - is a new feature
28 | - **doc** - documentation only changes
29 | - **cicd** - changes related to CI/CD system
30 | - **fix** - a bug fix
31 | - **refactor** - code change that neither fixes a bug nor adds a feature
32 |
33 | **All PRs must include a commit message with the changes description!**
34 |
35 | For the initial start, fork the project and use git clone command to download the repository to your computer. A standard procedure for working on an issue would be to:
36 |
37 | 1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date.
38 |
39 | ```
40 | $ git pull
41 | ```
42 |
43 | 2. Create new branch from `master` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
44 |
45 | ```
46 | $ git checkout -b [name_of_your_new_branch]
47 | ```
48 |
49 | 3. Work - commit - repeat ( be sure to be in your branch )
50 |
51 | 4. Push changes to GitHub
52 |
53 | ```
54 | $ git push origin [name_of_your_new_branch]
55 | ```
56 |
57 | 5. Submit your changes for review
58 | If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
59 | 6. Start a Pull Request
60 | Now submit the pull request and click on `Create pull request`.
61 | 7. Get a code review approval/reject
62 | 8. After approval, merge your PR
63 | 9. GitHub will automatically delete the branch after the merge is done. (they can still be restored).
64 |
65 | ## Introducing New Features
66 |
67 | We would 💖 you to contribute to Utopia-php, but we would also like to make sure Utopia-php is as great as possible and loyal to its vision and mission statement 🙏.
68 |
69 | For us to find the right balance, please open an issue explaining your ideas before introducing a new pull request.
70 |
71 | This will allow the Utopia-php community to have sufficient discussion about the new feature value and how it fits in the product roadmap and vision.
72 |
73 | This is also important for the Utopia-php lead developers to be able to give technical input and different emphasis regarding the feature design and architecture. Some bigger features might need to go through our [RFC process](https://github.com/appwrite/rfc).
74 |
75 | ## Other Ways to Help
76 |
77 | Pull requests are great, but there are many other areas where you can help Utopia-php.
78 |
79 | ### Blogging & Speaking
80 |
81 | Blogging, speaking about, or creating tutorials about one of Utopia-php’s many features is great way to contribute and help our project grow.
82 |
83 | ### Presenting at Meetups
84 |
85 | Presenting at meetups and conferences about your Utopia-php projects. Your unique challenges and successes in building things with Utopia-php can provide great speaking material. We’d love to review your talk abstract/CFP, so get in touch with us if you’d like some help!
86 |
87 | ### Sending Feedbacks & Reporting Bugs
88 |
89 | Sending feedback is a great way for us to understand your different use cases of Utopia-php better. If you had any issues, bugs, or want to share about your experience, feel free to do so on our GitHub issues page or at our [Discord channel](https://discord.gg/GSeTUeA).
90 |
91 | ### Submitting New Ideas
92 |
93 | If you think Utopia-php could use a new feature, please open an issue on our GitHub repository, stating as much information as you can think about your new idea and it's implications. We would also use this issue to gather more information, get more feedback from the community, and have a proper discussion about the new feature.
94 |
95 | ### Improving Documentation
96 |
97 | Submitting documentation updates, enhancements, designs, or bug fixes. Spelling or grammar fixes will be very much appreciated.
98 |
99 | ### Helping Someone
100 |
101 | Searching for Utopia-php, GitHub or StackOverflow and helping someone else who needs help. You can also help by teaching others how to contribute to Utopia-php's repo!
--------------------------------------------------------------------------------
/src/Audit/Log.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | class Log extends ArrayObject
16 | {
17 | /**
18 | * Construct a new audit log object.
19 | *
20 | * @param array $input
21 | */
22 | public function __construct(array $input = [])
23 | {
24 | parent::__construct($input);
25 | }
26 |
27 | /**
28 | * Get the log ID.
29 | *
30 | * @return string
31 | */
32 | public function getId(): string
33 | {
34 | $id = $this->getAttribute('$id', '');
35 | return is_string($id) ? $id : '';
36 | }
37 |
38 | /**
39 | * Get the user ID associated with this log entry.
40 | *
41 | * @return string|null
42 | */
43 | public function getUserId(): ?string
44 | {
45 | $userId = $this->getAttribute('userId');
46 | return is_string($userId) ? $userId : null;
47 | }
48 |
49 | /**
50 | * Get the event name.
51 | *
52 | * @return string
53 | */
54 | public function getEvent(): string
55 | {
56 | $event = $this->getAttribute('event', '');
57 | return is_string($event) ? $event : '';
58 | }
59 |
60 | /**
61 | * Get the resource identifier.
62 | *
63 | * @return string
64 | */
65 | public function getResource(): string
66 | {
67 | $resource = $this->getAttribute('resource', '');
68 | return is_string($resource) ? $resource : '';
69 | }
70 |
71 | /**
72 | * Get the user agent string.
73 | *
74 | * @return string
75 | */
76 | public function getUserAgent(): string
77 | {
78 | $userAgent = $this->getAttribute('userAgent', '');
79 | return is_string($userAgent) ? $userAgent : '';
80 | }
81 |
82 | /**
83 | * Get the IP address.
84 | *
85 | * @return string
86 | */
87 | public function getIp(): string
88 | {
89 | $ip = $this->getAttribute('ip', '');
90 | return is_string($ip) ? $ip : '';
91 | }
92 |
93 | /**
94 | * Get the location information.
95 | *
96 | * @return string|null
97 | */
98 | public function getLocation(): ?string
99 | {
100 | $location = $this->getAttribute('location');
101 | return is_string($location) ? $location : null;
102 | }
103 |
104 | /**
105 | * Get the timestamp.
106 | *
107 | * @return string
108 | */
109 | public function getTime(): string
110 | {
111 | $time = $this->getAttribute('time', '');
112 | return is_string($time) ? $time : '';
113 | }
114 |
115 | /**
116 | * Get the additional data.
117 | *
118 | * @return array
119 | */
120 | public function getData(): array
121 | {
122 | $data = $this->getAttribute('data', []);
123 | return is_array($data) ? $data : [];
124 | }
125 |
126 | /**
127 | * Get the tenant ID (for multi-tenant setups).
128 | *
129 | * @return int|null
130 | */
131 | public function getTenant(): ?int
132 | {
133 | $tenant = $this->getAttribute('tenant');
134 |
135 | if ($tenant === null) {
136 | return null;
137 | }
138 |
139 | if (is_int($tenant)) {
140 | return $tenant;
141 | }
142 |
143 | if (is_numeric($tenant)) {
144 | return (int) $tenant;
145 | }
146 |
147 | return null;
148 | }
149 |
150 | /**
151 | * Get an attribute by key.
152 | *
153 | * @param string $key
154 | * @param mixed $default
155 | * @return mixed
156 | */
157 | public function getAttribute(string $key, mixed $default = null): mixed
158 | {
159 | return $this->offsetExists($key) ? $this->offsetGet($key) : $default;
160 | }
161 |
162 | /**
163 | * Set an attribute.
164 | *
165 | * @param string $key
166 | * @param mixed $value
167 | * @return self
168 | */
169 | public function setAttribute(string $key, mixed $value): self
170 | {
171 | $this->offsetSet($key, $value);
172 | return $this;
173 | }
174 |
175 | /**
176 | * Remove an attribute.
177 | *
178 | * @param string $key
179 | * @return self
180 | */
181 | public function removeAttribute(string $key): self
182 | {
183 | if ($this->offsetExists($key)) {
184 | $this->offsetUnset($key);
185 | }
186 | return $this;
187 | }
188 |
189 | /**
190 | * Check if an attribute exists.
191 | *
192 | * @param string $key
193 | * @return bool
194 | */
195 | public function isSet(string $key): bool
196 | {
197 | return $this->offsetExists($key);
198 | }
199 |
200 | /**
201 | * Get all attributes as an array.
202 | *
203 | * @return array
204 | */
205 | public function getArrayCopy(): array
206 | {
207 | return parent::getArrayCopy();
208 | }
209 | }
210 |
--------------------------------------------------------------------------------
/src/Audit/Adapter.php:
--------------------------------------------------------------------------------
1 |
38 | * } $log
39 | * @return Log The created log entry
40 | *
41 | * @throws \Exception
42 | */
43 | abstract public function create(array $log): Log;
44 |
45 | /**
46 | * Create multiple audit log entries in batch.
47 | *
48 | * @param array
57 | * }> $logs
58 | * @return array
59 | *
60 | * @throws \Exception
61 | */
62 | abstract public function createBatch(array $logs): array;
63 |
64 | /**
65 | * Get logs by user ID.
66 | *
67 | * @param string $userId
68 | * @return array
69 | *
70 | * @throws \Exception
71 | */
72 | abstract public function getByUser(
73 | string $userId,
74 | ?\DateTime $after = null,
75 | ?\DateTime $before = null,
76 | int $limit = 25,
77 | int $offset = 0,
78 | bool $ascending = false,
79 | ): array;
80 |
81 | /**
82 | * Count logs by user ID.
83 | *
84 | * @param string $userId
85 | * @return int
86 | *
87 | * @throws \Exception
88 | */
89 | abstract public function countByUser(
90 | string $userId,
91 | ?\DateTime $after = null,
92 | ?\DateTime $before = null,
93 | ): int;
94 |
95 | /**
96 | * Get logs by resource.
97 | *
98 | * @param string $resource
99 | * @return array
100 | *
101 | * @throws \Exception
102 | */
103 | abstract public function getByResource(
104 | string $resource,
105 | ?\DateTime $after = null,
106 | ?\DateTime $before = null,
107 | int $limit = 25,
108 | int $offset = 0,
109 | bool $ascending = false,
110 | ): array;
111 |
112 | /**
113 | * Count logs by resource.
114 | *
115 | * @param string $resource
116 | * @return int
117 | *
118 | * @throws \Exception
119 | */
120 | abstract public function countByResource(
121 | string $resource,
122 | ?\DateTime $after = null,
123 | ?\DateTime $before = null,
124 | ): int;
125 |
126 | /**
127 | * Get logs by user and events.
128 | *
129 | * @param string $userId
130 | * @param array $events
131 | * @return array
132 | *
133 | * @throws \Exception
134 | */
135 | abstract public function getByUserAndEvents(
136 | string $userId,
137 | array $events,
138 | ?\DateTime $after = null,
139 | ?\DateTime $before = null,
140 | int $limit = 25,
141 | int $offset = 0,
142 | bool $ascending = false,
143 | ): array;
144 |
145 | /**
146 | * Count logs by user and events.
147 | *
148 | * @param string $userId
149 | * @param array $events
150 | * @return int
151 | *
152 | * @throws \Exception
153 | */
154 | abstract public function countByUserAndEvents(
155 | string $userId,
156 | array $events,
157 | ?\DateTime $after = null,
158 | ?\DateTime $before = null,
159 | ): int;
160 |
161 | /**
162 | * Get logs by resource and events.
163 | *
164 | * @param string $resource
165 | * @param array $events
166 | * @return array
167 | *
168 | * @throws \Exception
169 | */
170 | abstract public function getByResourceAndEvents(
171 | string $resource,
172 | array $events,
173 | ?\DateTime $after = null,
174 | ?\DateTime $before = null,
175 | int $limit = 25,
176 | int $offset = 0,
177 | bool $ascending = false,
178 | ): array;
179 |
180 | /**
181 | * Count logs by resource and events.
182 | *
183 | * @param string $resource
184 | * @param array $events
185 | * @return int
186 | *
187 | * @throws \Exception
188 | */
189 | abstract public function countByResourceAndEvents(
190 | string $resource,
191 | array $events,
192 | ?\DateTime $after = null,
193 | ?\DateTime $before = null,
194 | ): int;
195 |
196 | /**
197 | * Delete logs older than the specified datetime.
198 | *
199 | * @param \DateTime $datetime
200 | * @return bool
201 | *
202 | * @throws \Exception
203 | */
204 | abstract public function cleanup(\DateTime $datetime): bool;
205 | }
206 |
--------------------------------------------------------------------------------
/src/Audit/Adapter/SQL.php:
--------------------------------------------------------------------------------
1 |
40 | *
41 | * @return array>
42 | */
43 | protected function getAttributes(): array
44 | {
45 | return [
46 | [
47 | '$id' => 'userId',
48 | 'type' => Database::VAR_STRING,
49 | 'size' => Database::LENGTH_KEY,
50 | 'required' => false,
51 | 'signed' => true,
52 | 'array' => false,
53 | 'filters' => [],
54 | ],
55 | [
56 | '$id' => 'event',
57 | 'type' => Database::VAR_STRING,
58 | 'size' => 255,
59 | 'required' => true,
60 | 'signed' => true,
61 | 'array' => false,
62 | 'filters' => [],
63 | ],
64 | [
65 | '$id' => 'resource',
66 | 'type' => Database::VAR_STRING,
67 | 'size' => 255,
68 | 'required' => false,
69 | 'signed' => true,
70 | 'array' => false,
71 | 'filters' => [],
72 | ],
73 | [
74 | '$id' => 'userAgent',
75 | 'type' => Database::VAR_STRING,
76 | 'size' => 65534,
77 | 'required' => true,
78 | 'signed' => true,
79 | 'array' => false,
80 | 'filters' => [],
81 | ],
82 | [
83 | '$id' => 'ip',
84 | 'type' => Database::VAR_STRING,
85 | 'size' => 45,
86 | 'required' => true,
87 | 'signed' => true,
88 | 'array' => false,
89 | 'filters' => [],
90 | ],
91 | [
92 | '$id' => 'location',
93 | 'type' => Database::VAR_STRING,
94 | 'size' => 45,
95 | 'required' => false,
96 | 'signed' => true,
97 | 'array' => false,
98 | 'filters' => [],
99 | ],
100 | [
101 | '$id' => 'time',
102 | 'type' => Database::VAR_DATETIME,
103 | 'format' => '',
104 | 'size' => 0,
105 | 'signed' => true,
106 | 'required' => false,
107 | 'array' => false,
108 | 'filters' => ['datetime'],
109 | ],
110 | [
111 | '$id' => 'data',
112 | 'type' => Database::VAR_STRING,
113 | 'size' => 16777216,
114 | 'required' => false,
115 | 'signed' => true,
116 | 'array' => false,
117 | 'filters' => ['json'],
118 | ],
119 | ];
120 | }
121 |
122 | /**
123 | * Get attribute documents for audit logs.
124 | *
125 | * @return array
126 | */
127 | protected function getAttributeDocuments(): array
128 | {
129 | return array_map(static fn (array $attribute) => new Document($attribute), $this->getAttributes());
130 | }
131 |
132 | /**
133 | * Get index definitions for audit logs.
134 | *
135 | * Each index is an array with the following string keys:
136 | * - $id: string (index identifier)
137 | * - type: string
138 | * - attributes: array
139 | *
140 | * @return array>
141 | */
142 | protected function getIndexes(): array
143 | {
144 | return [
145 | [
146 | '$id' => 'idx_event',
147 | 'type' => 'key',
148 | 'attributes' => ['event'],
149 | ],
150 | [
151 | '$id' => 'idx_userId_event',
152 | 'type' => 'key',
153 | 'attributes' => ['userId', 'event'],
154 | ],
155 | [
156 | '$id' => 'idx_resource_event',
157 | 'type' => 'key',
158 | 'attributes' => ['resource', 'event'],
159 | ],
160 | [
161 | '$id' => 'idx_time_desc',
162 | 'type' => 'key',
163 | 'attributes' => ['time'],
164 | ],
165 | ];
166 | }
167 |
168 | /**
169 | * Get index documents for audit logs.
170 | *
171 | * @return array
172 | */
173 | protected function getIndexDocuments(): array
174 | {
175 | return array_map(static fn (array $index) => new Document($index), $this->getIndexes());
176 | }
177 |
178 | /**
179 | * Get a single attribute by ID.
180 | *
181 | * @param string $id
182 | * @return array|null
183 | */
184 | protected function getAttribute(string $id)
185 | {
186 | foreach ($this->getAttributes() as $attribute) {
187 | if ($attribute['$id'] === $id) {
188 | return $attribute;
189 | }
190 | }
191 |
192 | return null;
193 | }
194 |
195 | /**
196 | * Get SQL column definition for a given attribute ID.
197 | * This method is database-specific and must be implemented by each concrete adapter.
198 | *
199 | * @param string $id Attribute identifier
200 | * @return string Database-specific column definition
201 | */
202 | abstract protected function getColumnDefinition(string $id): string;
203 |
204 | /**
205 | * Get all SQL column definitions.
206 | * Uses the concrete adapter's implementation of getColumnDefinition.
207 | *
208 | * @return array
209 | */
210 | protected function getAllColumnDefinitions(): array
211 | {
212 | $definitions = [];
213 | foreach ($this->getAttributes() as $attribute) {
214 | /** @var string $id */
215 | $id = $attribute['$id'];
216 | $definitions[] = $this->getColumnDefinition($id);
217 | }
218 |
219 | return $definitions;
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/src/Audit/Audit.php:
--------------------------------------------------------------------------------
1 | adapter = $adapter;
24 | }
25 |
26 | /**
27 | * Get the current adapter.
28 | *
29 | * @return Adapter
30 | */
31 | public function getAdapter(): Adapter
32 | {
33 | return $this->adapter;
34 | }
35 |
36 | /**
37 | * Setup the audit log storage.
38 | *
39 | * @return void
40 | * @throws \Exception
41 | */
42 | public function setup(): void
43 | {
44 | $this->adapter->setup();
45 | }
46 |
47 | /**
48 | * Add event log.
49 | *
50 | * @param string|null $userId
51 | * @param string $event
52 | * @param string $resource
53 | * @param string $userAgent
54 | * @param string $ip
55 | * @param string $location
56 | * @param array $data
57 | * @return Log
58 | *
59 | * @throws \Exception
60 | */
61 | public function log(?string $userId, string $event, string $resource, string $userAgent, string $ip, string $location, array $data = []): Log
62 | {
63 | return $this->adapter->create([
64 | 'userId' => $userId,
65 | 'event' => $event,
66 | 'resource' => $resource,
67 | 'userAgent' => $userAgent,
68 | 'ip' => $ip,
69 | 'location' => $location,
70 | 'data' => $data,
71 | ]);
72 | }
73 |
74 | /**
75 | * Add multiple event logs in batch.
76 | *
77 | * @param array}> $events
78 | * @return array
79 | *
80 | * @throws \Exception
81 | */
82 | public function logBatch(array $events): array
83 | {
84 | return $this->adapter->createBatch($events);
85 | }
86 |
87 | /**
88 | * Get all logs by user ID.
89 | *
90 | * @param string $userId
91 | * @return array
92 | *
93 | * @throws \Exception
94 | */
95 | public function getLogsByUser(
96 | string $userId,
97 | ?\DateTime $after = null,
98 | ?\DateTime $before = null,
99 | int $limit = 25,
100 | int $offset = 0,
101 | bool $ascending = false,
102 | ): array {
103 | return $this->adapter->getByUser($userId, $after, $before, $limit, $offset, $ascending);
104 | }
105 |
106 | /**
107 | * Count logs by user ID.
108 | *
109 | * @param string $userId
110 | * @return int
111 | * @throws \Exception
112 | */
113 | public function countLogsByUser(
114 | string $userId,
115 | ?\DateTime $after = null,
116 | ?\DateTime $before = null,
117 | ): int {
118 | return $this->adapter->countByUser($userId, $after, $before);
119 | }
120 |
121 | /**
122 | * Get all logs by resource.
123 | *
124 | * @param string $resource
125 | * @return array
126 | *
127 | * @throws \Exception
128 | */
129 | public function getLogsByResource(
130 | string $resource,
131 | ?\DateTime $after = null,
132 | ?\DateTime $before = null,
133 | int $limit = 25,
134 | int $offset = 0,
135 | bool $ascending = false,
136 | ): array {
137 | return $this->adapter->getByResource($resource, $after, $before, $limit, $offset, $ascending);
138 | }
139 |
140 | /**
141 | * Count logs by resource.
142 | *
143 | * @param string $resource
144 | * @return int
145 | *
146 | * @throws \Exception
147 | */
148 | public function countLogsByResource(
149 | string $resource,
150 | ?\DateTime $after = null,
151 | ?\DateTime $before = null,
152 | ): int {
153 | return $this->adapter->countByResource($resource, $after, $before);
154 | }
155 |
156 | /**
157 | * Get logs by user and events.
158 | *
159 | * @param string $userId
160 | * @param array $events
161 | * @return array
162 | *
163 | * @throws \Exception
164 | */
165 | public function getLogsByUserAndEvents(
166 | string $userId,
167 | array $events,
168 | ?\DateTime $after = null,
169 | ?\DateTime $before = null,
170 | int $limit = 25,
171 | int $offset = 0,
172 | bool $ascending = false,
173 | ): array {
174 | return $this->adapter->getByUserAndEvents($userId, $events, $after, $before, $limit, $offset, $ascending);
175 | }
176 |
177 | /**
178 | * Count logs by user and events.
179 | *
180 | * @param string $userId
181 | * @param array $events
182 | * @return int
183 | *
184 | * @throws \Exception
185 | */
186 | public function countLogsByUserAndEvents(
187 | string $userId,
188 | array $events,
189 | ?\DateTime $after = null,
190 | ?\DateTime $before = null,
191 | ): int {
192 | return $this->adapter->countByUserAndEvents($userId, $events, $after, $before);
193 | }
194 |
195 | /**
196 | * Get logs by resource and events.
197 | *
198 | * @param string $resource
199 | * @param array $events
200 | * @return array
201 | *
202 | * @throws \Exception
203 | */
204 | public function getLogsByResourceAndEvents(
205 | string $resource,
206 | array $events,
207 | ?\DateTime $after = null,
208 | ?\DateTime $before = null,
209 | int $limit = 25,
210 | int $offset = 0,
211 | bool $ascending = false,
212 | ): array {
213 | return $this->adapter->getByResourceAndEvents($resource, $events, $after, $before, $limit, $offset, $ascending);
214 | }
215 |
216 | /**
217 | * Count logs by resource and events.
218 | *
219 | * @param string $resource
220 | * @param array $events
221 | * @return int
222 | *
223 | * @throws \Exception
224 | */
225 | public function countLogsByResourceAndEvents(
226 | string $resource,
227 | array $events,
228 | ?\DateTime $after = null,
229 | ?\DateTime $before = null,
230 | ): int {
231 | return $this->adapter->countByResourceAndEvents($resource, $events, $after, $before);
232 | }
233 |
234 | /**
235 | * Delete all logs older than the specified datetime
236 | *
237 | * @param \DateTime $datetime
238 | * @return bool
239 | *
240 | * @throws \Exception
241 | */
242 | public function cleanup(\DateTime $datetime): bool
243 | {
244 | return $this->adapter->cleanup($datetime);
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/tests/Audit/Adapter/ClickHouseTest.php:
--------------------------------------------------------------------------------
1 | setDatabase('default');
31 |
32 | $this->audit = new Audit($clickHouse);
33 | $this->audit->setup();
34 | }
35 |
36 | /**
37 | * Test constructor validates host
38 | */
39 | public function testConstructorValidatesHost(): void
40 | {
41 | $this->expectException(Exception::class);
42 | $this->expectExceptionMessage('ClickHouse host is not a valid hostname or IP address');
43 |
44 | new ClickHouse(
45 | host: '',
46 | username: 'default',
47 | password: ''
48 | );
49 | }
50 |
51 | /**
52 | * Test constructor validates port range
53 | */
54 | public function testConstructorValidatesPortTooLow(): void
55 | {
56 | $this->expectException(Exception::class);
57 | $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535');
58 |
59 | new ClickHouse(
60 | host: 'localhost',
61 | username: 'default',
62 | password: '',
63 | port: 0
64 | );
65 | }
66 |
67 | /**
68 | * Test constructor validates port range upper bound
69 | */
70 | public function testConstructorValidatesPortTooHigh(): void
71 | {
72 | $this->expectException(Exception::class);
73 | $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535');
74 |
75 | new ClickHouse(
76 | host: 'localhost',
77 | username: 'default',
78 | password: '',
79 | port: 65536
80 | );
81 | }
82 |
83 | /**
84 | * Test constructor with valid parameters
85 | */
86 | public function testConstructorWithValidParameters(): void
87 | {
88 | $adapter = new ClickHouse(
89 | host: 'clickhouse',
90 | username: 'testuser',
91 | password: 'testpass',
92 | port: 8443,
93 | secure: true
94 | );
95 |
96 | $this->assertInstanceOf(ClickHouse::class, $adapter);
97 | $this->assertEquals('ClickHouse', $adapter->getName());
98 | }
99 |
100 | /**
101 | * Test getName returns correct adapter name
102 | */
103 | public function testGetName(): void
104 | {
105 | $adapter = new ClickHouse(
106 | host: 'clickhouse',
107 | username: 'default',
108 | password: 'clickhouse'
109 | );
110 |
111 | $this->assertEquals('ClickHouse', $adapter->getName());
112 | }
113 |
114 | /**
115 | * Test setDatabase validates empty identifier
116 | */
117 | public function testSetDatabaseValidatesEmpty(): void
118 | {
119 | $this->expectException(Exception::class);
120 | $this->expectExceptionMessage('Database cannot be empty');
121 |
122 | $adapter = new ClickHouse(
123 | host: 'clickhouse',
124 | username: 'default',
125 | password: 'clickhouse'
126 | );
127 |
128 | $adapter->setDatabase('');
129 | }
130 |
131 | /**
132 | * Test setDatabase validates identifier length
133 | */
134 | public function testSetDatabaseValidatesLength(): void
135 | {
136 | $this->expectException(Exception::class);
137 | $this->expectExceptionMessage('Database cannot exceed 255 characters');
138 |
139 | $adapter = new ClickHouse(
140 | host: 'clickhouse',
141 | username: 'default',
142 | password: 'clickhouse'
143 | );
144 |
145 | $adapter->setDatabase(str_repeat('a', 256));
146 | }
147 |
148 | /**
149 | * Test setDatabase validates identifier format
150 | */
151 | public function testSetDatabaseValidatesFormat(): void
152 | {
153 | $this->expectException(Exception::class);
154 | $this->expectExceptionMessage('Database must start with a letter or underscore');
155 |
156 | $adapter = new ClickHouse(
157 | host: 'clickhouse',
158 | username: 'default',
159 | password: 'clickhouse'
160 | );
161 |
162 | $adapter->setDatabase('123invalid');
163 | }
164 |
165 | /**
166 | * Test setDatabase rejects SQL keywords
167 | */
168 | public function testSetDatabaseRejectsKeywords(): void
169 | {
170 | $this->expectException(Exception::class);
171 | $this->expectExceptionMessage('Database cannot be a reserved SQL keyword');
172 |
173 | $adapter = new ClickHouse(
174 | host: 'clickhouse',
175 | username: 'default',
176 | password: 'clickhouse'
177 | );
178 |
179 | $adapter->setDatabase('SELECT');
180 | }
181 |
182 | /**
183 | * Test setDatabase with valid identifier
184 | */
185 | public function testSetDatabaseWithValidIdentifier(): void
186 | {
187 | $adapter = new ClickHouse(
188 | host: 'clickhouse',
189 | username: 'default',
190 | password: 'clickhouse'
191 | );
192 |
193 | $result = $adapter->setDatabase('my_database_123');
194 | $this->assertInstanceOf(ClickHouse::class, $result);
195 | }
196 |
197 | /**
198 | * Test setNamespace allows empty string
199 | */
200 | public function testSetNamespaceAllowsEmpty(): void
201 | {
202 | $adapter = new ClickHouse(
203 | host: 'clickhouse',
204 | username: 'default',
205 | password: 'clickhouse'
206 | );
207 |
208 | $result = $adapter->setNamespace('');
209 | $this->assertInstanceOf(ClickHouse::class, $result);
210 | $this->assertEquals('', $adapter->getNamespace());
211 | }
212 |
213 | /**
214 | * Test setNamespace validates identifier format
215 | */
216 | public function testSetNamespaceValidatesFormat(): void
217 | {
218 | $this->expectException(Exception::class);
219 | $this->expectExceptionMessage('Namespace must start with a letter or underscore');
220 |
221 | $adapter = new ClickHouse(
222 | host: 'clickhouse',
223 | username: 'default',
224 | password: 'clickhouse'
225 | );
226 |
227 | $adapter->setNamespace('9invalid');
228 | }
229 |
230 | /**
231 | * Test setNamespace with valid identifier
232 | */
233 | public function testSetNamespaceWithValidIdentifier(): void
234 | {
235 | $adapter = new ClickHouse(
236 | host: 'clickhouse',
237 | username: 'default',
238 | password: 'clickhouse'
239 | );
240 |
241 | $result = $adapter->setNamespace('project_123');
242 | $this->assertInstanceOf(ClickHouse::class, $result);
243 | $this->assertEquals('project_123', $adapter->getNamespace());
244 | }
245 |
246 | /**
247 | * Test setSecure method
248 | */
249 | public function testSetSecure(): void
250 | {
251 | $adapter = new ClickHouse(
252 | host: 'clickhouse',
253 | username: 'default',
254 | password: 'clickhouse',
255 | port: 8123,
256 | secure: false
257 | );
258 |
259 | $result = $adapter->setSecure(true);
260 | $this->assertInstanceOf(ClickHouse::class, $result);
261 | }
262 |
263 | /**
264 | * Test shared tables configuration
265 | */
266 | public function testSharedTablesConfiguration(): void
267 | {
268 | $adapter = new ClickHouse(
269 | host: 'clickhouse',
270 | username: 'default',
271 | password: 'clickhouse'
272 | );
273 |
274 | // Test initial state
275 | $this->assertFalse($adapter->isSharedTables());
276 | $this->assertNull($adapter->getTenant());
277 |
278 | // Test setting shared tables
279 | $result = $adapter->setSharedTables(true);
280 | $this->assertInstanceOf(ClickHouse::class, $result);
281 | $this->assertTrue($adapter->isSharedTables());
282 |
283 | // Test setting tenant
284 | $result2 = $adapter->setTenant(12345);
285 | $this->assertInstanceOf(ClickHouse::class, $result2);
286 | $this->assertEquals(12345, $adapter->getTenant());
287 |
288 | // Test setting tenant to null
289 | $adapter->setTenant(null);
290 | $this->assertNull($adapter->getTenant());
291 | }
292 |
293 | /**
294 | * Test batch operations with special characters
295 | */
296 | public function testBatchOperationsWithSpecialCharacters(): void
297 | {
298 | // Test batch with special characters in data
299 | $batchEvents = [
300 | [
301 | 'userId' => 'user`with`backticks',
302 | 'event' => 'create',
303 | 'resource' => 'doc/"quotes"',
304 | 'userAgent' => "User'Agent\"With'Quotes",
305 | 'ip' => '192.168.1.1',
306 | 'location' => 'UK',
307 | 'data' => ['special' => "data with 'quotes'"],
308 | 'time' => \Utopia\Database\DateTime::formatTz(\Utopia\Database\DateTime::now()) ?? ''
309 | ]
310 | ];
311 |
312 | $result = $this->audit->logBatch($batchEvents);
313 | $this->assertEquals(1, count($result));
314 |
315 | // Verify retrieval
316 | $logs = $this->audit->getLogsByUser('user`with`backticks');
317 | $this->assertGreaterThan(0, count($logs));
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Utopia Audit
2 |
3 | [](https://travis-ci.com/utopia-php/audit)
4 | 
5 | [](https://appwrite.io/discord)
6 |
7 | Utopia framework audit library is simple and lite library for managing application user logs. This library is aiming to be as simple and easy to learn and use. This library is maintained by the [Appwrite team](https://appwrite.io).
8 |
9 | Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project it is dependency free, and can be used as standalone with any other PHP project or framework.
10 |
11 | ## Features
12 |
13 | - **Adapter Pattern**: Support for multiple storage backends through adapters
14 | - **Default Database Adapter**: Built-in support for utopia-php/database
15 | - **Extensible**: Easy to create custom adapters for different storage solutions
16 | - **Batch Operations**: Support for logging multiple events at once
17 | - **Query Support**: Rich querying capabilities for retrieving logs
18 |
19 | ## Getting Started
20 |
21 | Install using composer:
22 | ```bash
23 | composer require utopia-php/audit
24 | ```
25 |
26 | ## Usage
27 |
28 | ### Using the Database Adapter (Default)
29 |
30 | The simplest way to use Utopia Audit is with the built-in Database adapter:
31 |
32 | ```php
33 | 3,
52 | PDO::ATTR_PERSISTENT => true,
53 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
54 | PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
55 | PDO::ATTR_EMULATE_PREPARES => true,
56 | PDO::ATTR_STRINGIFY_FETCHES => true,
57 | ]);
58 |
59 | $cache = new Cache(new NoCache());
60 |
61 | $database = new Database(new MySQL($pdo), $cache);
62 | $database->setNamespace('namespace');
63 |
64 | // Create audit instance with Database adapter
65 | $audit = new Audit(new DatabaseAdapter($database));
66 | $audit->setup();
67 | ```
68 |
69 | ### Using a Custom Adapter
70 |
71 | You can create custom adapters by extending the `Utopia\Audit\Adapter` abstract class:
72 |
73 | ```php
74 | 'value1','key2' => 'value2']; // Any key-value pair you need to log
99 |
100 | $audit->log($userId, $event, $resource, $userAgent, $ip, $location, $data);
101 | ```
102 |
103 | **Get Logs By User**
104 |
105 | Fetch all logs by given user ID with optional filtering parameters:
106 |
107 | ```php
108 | // Basic usage
109 | $logs = $audit->getLogsByUser('userId');
110 |
111 | // With pagination
112 | $logs = $audit->getLogsByUser(
113 | userId: 'userId',
114 | limit: 10,
115 | offset: 0
116 | );
117 |
118 | // With time filtering using DateTime objects
119 | $logs = $audit->getLogsByUser(
120 | userId: 'userId',
121 | after: new \DateTime('2024-01-01 00:00:00'),
122 | before: new \DateTime('2024-12-31 23:59:59'),
123 | limit: 25,
124 | offset: 0,
125 | ascending: false // false = newest first (default), true = oldest first
126 | );
127 | ```
128 |
129 | **Get Logs By User and Action**
130 |
131 | Fetch all logs by given user ID and specific event names with optional filtering:
132 |
133 | ```php
134 | // Basic usage
135 | $logs = $audit->getLogsByUserAndEvents(
136 | userId: 'userId',
137 | events: ['update', 'delete']
138 | );
139 |
140 | // With time filtering and pagination
141 | $logs = $audit->getLogsByUserAndEvents(
142 | userId: 'userId',
143 | events: ['update', 'delete'],
144 | after: new \DateTime('-7 days'),
145 | before: new \DateTime('now'),
146 | limit: 50,
147 | offset: 0,
148 | ascending: false
149 | );
150 | ```
151 |
152 | **Get Logs By Resource**
153 |
154 | Fetch all logs by a given resource name with optional filtering:
155 |
156 | ```php
157 | // Basic usage
158 | $logs = $audit->getLogsByResource('database/document-1');
159 |
160 | // With time filtering and pagination
161 | $logs = $audit->getLogsByResource(
162 | resource: 'database/document-1',
163 | after: new \DateTime('-30 days'),
164 | before: new \DateTime('now'),
165 | limit: 100,
166 | offset: 0,
167 | ascending: true // Get oldest logs first
168 | );
169 | ```
170 |
171 | **Batch Logging**
172 |
173 | Log multiple events at once for better performance:
174 |
175 | ```php
176 | use Utopia\Database\DateTime;
177 |
178 | $events = [
179 | [
180 | 'userId' => 'user-1',
181 | 'event' => 'create',
182 | 'resource' => 'database/document/1',
183 | 'userAgent' => 'Mozilla/5.0...',
184 | 'ip' => '127.0.0.1',
185 | 'location' => 'US',
186 | 'data' => ['key' => 'value'],
187 | 'time' => DateTime::now()
188 | ],
189 | [
190 | 'userId' => 'user-2',
191 | 'event' => 'update',
192 | 'resource' => 'database/document/2',
193 | 'userAgent' => 'Mozilla/5.0...',
194 | 'ip' => '192.168.1.1',
195 | 'location' => 'UK',
196 | 'data' => ['key' => 'value'],
197 | 'time' => DateTime::now()
198 | ]
199 | ];
200 |
201 | $documents = $audit->logBatch($events);
202 | ```
203 |
204 | **Counting Logs**
205 |
206 | All retrieval methods have corresponding count methods with the same filtering capabilities:
207 |
208 | ```php
209 | // Count all logs for a user
210 | $count = $audit->countLogsByUser('userId');
211 |
212 | // Count logs within a time range
213 | $count = $audit->countLogsByUser(
214 | userId: 'userId',
215 | after: new \DateTime('-7 days'),
216 | before: new \DateTime('now')
217 | );
218 |
219 | // Count logs by resource
220 | $count = $audit->countLogsByResource('database/document-1');
221 |
222 | // Count logs by user and events
223 | $count = $audit->countLogsByUserAndEvents(
224 | userId: 'userId',
225 | events: ['create', 'update', 'delete'],
226 | after: new \DateTime('-30 days')
227 | );
228 |
229 | // Count logs by resource and events
230 | $count = $audit->countLogsByResourceAndEvents(
231 | resource: 'database/document-1',
232 | events: ['update', 'delete']
233 | );
234 | ```
235 |
236 | **Advanced Filtering**
237 |
238 | Get logs by resource and specific events:
239 |
240 | ```php
241 | $logs = $audit->getLogsByResourceAndEvents(
242 | resource: 'database/document-1',
243 | events: ['create', 'update'],
244 | after: new \DateTime('-24 hours'),
245 | limit: 20,
246 | offset: 0,
247 | ascending: false
248 | );
249 | ```
250 |
251 | ### Filtering Parameters
252 |
253 | All retrieval methods support the following optional parameters:
254 |
255 | - **after** (`?\DateTime`): Get logs created after this datetime
256 | - **before** (`?\DateTime`): Get logs created before this datetime
257 | - **limit** (`int`, default: 25): Maximum number of logs to return
258 | - **offset** (`int`, default: 0): Number of logs to skip (for pagination)
259 | - **ascending** (`bool`, default: false): Sort order - false for newest first, true for oldest first
260 |
261 | ## Adapters
262 |
263 | Utopia Audit uses an adapter pattern to support different storage backends. Currently available adapters:
264 |
265 | ### Database Adapter (Default)
266 |
267 | The Database adapter uses [utopia-php/database](https://github.com/utopia-php/database) to store audit logs in a database.
268 |
269 |
270 | ### ClickHouse Adapter
271 |
272 | The ClickHouse adapter uses [ClickHouse](https://clickhouse.com/) for high-performance analytical queries on massive amounts of log data. It communicates with ClickHouse via HTTP interface using Utopia Fetch.
273 |
274 | **Features:**
275 | - Optimized for analytical queries and aggregations
276 | - Handles billions of log entries efficiently
277 | - Column-oriented storage for fast queries
278 | - Automatic partitioning by month
279 | - Bloom filter indexes for fast lookups
280 |
281 | **Usage:**
282 |
283 | ```php
284 | setup(); // Creates database and table
301 |
302 | // Use as normal
303 | $document = $audit->log(
304 | userId: 'user-123',
305 | event: 'document.create',
306 | resource: 'database/document/1',
307 | userAgent: 'Mozilla/5.0...',
308 | ip: '127.0.0.1',
309 | location: 'US',
310 | data: ['key' => 'value']
311 | );
312 | ```
313 |
314 | **Performance Benefits:**
315 | - Ideal for high-volume logging (millions of events per day)
316 | - Fast aggregation queries (counts, analytics)
317 | - Efficient storage with compression
318 | - Automatic data partitioning and retention policies
319 |
320 | ### Creating Custom Adapters
321 |
322 | To create a custom adapter, extend the `Utopia\Audit\Adapter` abstract class and implement all required methods:
323 |
324 | ```php
325 | db = $db;
27 | }
28 |
29 | /**
30 | * Get adapter name.
31 | */
32 | public function getName(): string
33 | {
34 | return 'Database';
35 | }
36 |
37 | /**
38 | * Setup database structure.
39 | *
40 | * @return void
41 | * @throws Exception|\Exception
42 | */
43 | public function setup(): void
44 | {
45 | if (! $this->db->exists($this->db->getDatabase())) {
46 | throw new Exception('You need to create the database before running Audit setup');
47 | }
48 |
49 | $attributes = $this->getAttributeDocuments();
50 | $indexes = $this->getIndexDocuments();
51 |
52 | try {
53 | $this->db->createCollection(
54 | $this->getCollectionName(),
55 | $attributes,
56 | $indexes
57 | );
58 | } catch (DuplicateException) {
59 | // Collection already exists
60 | }
61 | }
62 |
63 | /**
64 | * Create an audit log entry.
65 | *
66 | * @param array $log
67 | * @return Log
68 | * @throws AuthorizationException|\Exception
69 | */
70 | public function create(array $log): Log
71 | {
72 | $log['time'] = $log['time'] ?? DateTime::now();
73 | $document = $this->db->getAuthorization()->skip(function () use ($log) {
74 | return $this->db->createDocument($this->getCollectionName(), new Document($log));
75 | });
76 |
77 | return new Log($document->getArrayCopy());
78 | }
79 |
80 | /**
81 | * Create multiple audit log entries in batch.
82 | *
83 | * @param array> $logs
84 | * @return array
85 | * @throws AuthorizationException|\Exception
86 | */
87 | public function createBatch(array $logs): array
88 | {
89 | $created = [];
90 |
91 | $this->db->getAuthorization()->skip(function () use ($logs, &$created) {
92 | foreach ($logs as $log) {
93 | $time = $log['time'] ?? new \DateTime();
94 | if (is_string($time)) {
95 | $time = new \DateTime($time);
96 | }
97 | assert($time instanceof \DateTime);
98 | $log['time'] = DateTime::format($time);
99 | $created[] = $this->db->createDocument($this->getCollectionName(), new Document($log));
100 | }
101 | });
102 |
103 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created);
104 | }
105 |
106 | /**
107 | * Build time-related query conditions.
108 | *
109 | * @param \DateTime|null $after
110 | * @param \DateTime|null $before
111 | * @return array
112 | */
113 | private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array
114 | {
115 | $queries = [];
116 |
117 | $afterStr = $after ? DateTime::format($after) : null;
118 | $beforeStr = $before ? DateTime::format($before) : null;
119 |
120 | if ($afterStr !== null && $beforeStr !== null) {
121 | $queries[] = Query::between('time', $afterStr, $beforeStr);
122 | return $queries;
123 | }
124 |
125 | if ($afterStr !== null) {
126 | $queries[] = Query::greaterThan('time', $afterStr);
127 | }
128 |
129 | if ($beforeStr !== null) {
130 | $queries[] = Query::lessThan('time', $beforeStr);
131 | }
132 |
133 | return $queries;
134 | }
135 |
136 | /**
137 | * Get audit logs by user ID.
138 | *
139 | * @return array
140 | * @throws AuthorizationException|\Exception
141 | */
142 | public function getByUser(
143 | string $userId,
144 | ?\DateTime $after = null,
145 | ?\DateTime $before = null,
146 | int $limit = 25,
147 | int $offset = 0,
148 | bool $ascending = false,
149 | ): array {
150 | $timeQueries = $this->buildTimeQueries($after, $before);
151 | $documents = $this->db->getAuthorization()->skip(function () use ($userId, $timeQueries, $limit, $offset, $ascending) {
152 | $queries = [
153 | Query::equal('userId', [$userId]),
154 | ...$timeQueries,
155 | $ascending ? Query::orderAsc('time') : Query::orderDesc('time'),
156 | Query::limit($limit),
157 | Query::offset($offset),
158 | ];
159 |
160 | return $this->db->find(
161 | collection: $this->getCollectionName(),
162 | queries: $queries,
163 | );
164 | });
165 |
166 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents);
167 | }
168 |
169 | /**
170 | * Count audit logs by user ID.
171 | *
172 | * @throws AuthorizationException|\Exception
173 | */
174 | public function countByUser(
175 | string $userId,
176 | ?\DateTime $after = null,
177 | ?\DateTime $before = null,
178 | ): int {
179 | $timeQueries = $this->buildTimeQueries($after, $before);
180 | return $this->db->getAuthorization()->skip(function () use ($userId, $timeQueries) {
181 | return $this->db->count(
182 | collection: $this->getCollectionName(),
183 | queries: [
184 | Query::equal('userId', [$userId]),
185 | ...$timeQueries,
186 | ]
187 | );
188 | });
189 | }
190 |
191 | /**
192 | * Get logs by resource.
193 | *
194 | * @param string $resource
195 | * @return array
196 | * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query
197 | */
198 | public function getByResource(
199 | string $resource,
200 | ?\DateTime $after = null,
201 | ?\DateTime $before = null,
202 | int $limit = 25,
203 | int $offset = 0,
204 | bool $ascending = false,
205 | ): array {
206 | $timeQueries = $this->buildTimeQueries($after, $before);
207 | $documents = $this->db->getAuthorization()->skip(function () use ($resource, $timeQueries, $limit, $offset, $ascending) {
208 | $queries = [
209 | Query::equal('resource', [$resource]),
210 | ...$timeQueries,
211 | $ascending ? Query::orderAsc('time') : Query::orderDesc('time'),
212 | Query::limit($limit),
213 | Query::offset($offset),
214 | ];
215 |
216 | return $this->db->find(
217 | collection: $this->getCollectionName(),
218 | queries: $queries,
219 | );
220 | });
221 |
222 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents);
223 | }
224 |
225 | /**
226 | * Count logs by resource.
227 | *
228 | * @param string $resource
229 | * @return int
230 | * @throws \Utopia\Database\Exception
231 | */
232 | public function countByResource(
233 | string $resource,
234 | ?\DateTime $after = null,
235 | ?\DateTime $before = null,
236 | ): int {
237 | $timeQueries = $this->buildTimeQueries($after, $before);
238 | return $this->db->getAuthorization()->skip(function () use ($resource, $timeQueries) {
239 | return $this->db->count(
240 | collection: $this->getCollectionName(),
241 | queries: [
242 | Query::equal('resource', [$resource]),
243 | ...$timeQueries,
244 | ]
245 | );
246 | });
247 | }
248 |
249 | /**
250 | * Get logs by user and events.
251 | *
252 | * @param string $userId
253 | * @param array $events
254 | * @return array
255 | * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query
256 | */
257 | public function getByUserAndEvents(
258 | string $userId,
259 | array $events,
260 | ?\DateTime $after = null,
261 | ?\DateTime $before = null,
262 | int $limit = 25,
263 | int $offset = 0,
264 | bool $ascending = false,
265 | ): array {
266 | $timeQueries = $this->buildTimeQueries($after, $before);
267 | $documents = $this->db->getAuthorization()->skip(function () use ($userId, $events, $timeQueries, $limit, $offset, $ascending) {
268 | $queries = [
269 | Query::equal('userId', [$userId]),
270 | Query::equal('event', $events),
271 | ...$timeQueries,
272 | $ascending ? Query::orderAsc('time') : Query::orderDesc('time'),
273 | Query::limit($limit),
274 | Query::offset($offset),
275 | ];
276 |
277 | return $this->db->find(
278 | collection: $this->getCollectionName(),
279 | queries: $queries,
280 | );
281 | });
282 |
283 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents);
284 | }
285 |
286 | /**
287 | * Count logs by user and events.
288 | *
289 | * @param string $userId
290 | * @param array $events
291 | * @return int
292 | * @throws \Utopia\Database\Exception
293 | */
294 | public function countByUserAndEvents(
295 | string $userId,
296 | array $events,
297 | ?\DateTime $after = null,
298 | ?\DateTime $before = null,
299 | ): int {
300 | $timeQueries = $this->buildTimeQueries($after, $before);
301 | return $this->db->getAuthorization()->skip(function () use ($userId, $events, $timeQueries) {
302 | return $this->db->count(
303 | collection: $this->getCollectionName(),
304 | queries: [
305 | Query::equal('userId', [$userId]),
306 | Query::equal('event', $events),
307 | ...$timeQueries,
308 | ]
309 | );
310 | });
311 | }
312 |
313 | /**
314 | * Get logs by resource and events.
315 | *
316 | * @param string $resource
317 | * @param array $events
318 | * @return array
319 | * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query
320 | */
321 | public function getByResourceAndEvents(
322 | string $resource,
323 | array $events,
324 | ?\DateTime $after = null,
325 | ?\DateTime $before = null,
326 | int $limit = 25,
327 | int $offset = 0,
328 | bool $ascending = false,
329 | ): array {
330 | $timeQueries = $this->buildTimeQueries($after, $before);
331 | $documents = $this->db->getAuthorization()->skip(function () use ($resource, $events, $timeQueries, $limit, $offset, $ascending) {
332 | $queries = [
333 | Query::equal('resource', [$resource]),
334 | Query::equal('event', $events),
335 | ...$timeQueries,
336 | $ascending ? Query::orderAsc('time') : Query::orderDesc('time'),
337 | Query::limit($limit),
338 | Query::offset($offset),
339 | ];
340 |
341 | return $this->db->find(
342 | collection: $this->getCollectionName(),
343 | queries: $queries,
344 | );
345 | });
346 |
347 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents);
348 | }
349 |
350 | /**
351 | * Count logs by resource and events.
352 | *
353 | * @param string $resource
354 | * @param array $events
355 | * @return int
356 | * @throws \Utopia\Database\Exception
357 | */
358 | public function countByResourceAndEvents(
359 | string $resource,
360 | array $events,
361 | ?\DateTime $after = null,
362 | ?\DateTime $before = null,
363 | ): int {
364 | $timeQueries = $this->buildTimeQueries($after, $before);
365 | return $this->db->getAuthorization()->skip(function () use ($resource, $events, $timeQueries) {
366 | return $this->db->count(
367 | collection: $this->getCollectionName(),
368 | queries: [
369 | Query::equal('resource', [$resource]),
370 | Query::equal('event', $events),
371 | ...$timeQueries,
372 | ]
373 | );
374 | });
375 | }
376 |
377 | /**
378 | * Delete logs older than the specified datetime.
379 | *
380 | * @param \DateTime $datetime
381 | /**
382 | * @throws AuthorizationException|\Exception
383 | */
384 | public function cleanup(\DateTime $datetime): bool
385 | {
386 | $datetimeString = DateTime::format($datetime);
387 | $this->db->getAuthorization()->skip(function () use ($datetimeString) {
388 | do {
389 | $removed = $this->db->deleteDocuments($this->getCollectionName(), [
390 | Query::lessThan('time', $datetimeString),
391 | ]);
392 | } while ($removed > 0);
393 | });
394 |
395 | return true;
396 | }
397 |
398 | /**
399 | * Get database-agnostic column definition for a given attribute ID.
400 | *
401 | * For the Database adapter, this method is not actively used since the adapter
402 | * delegates to utopia-php/database's native Document/Collection API which handles
403 | * type mapping internally. However, this implementation is required to satisfy
404 | * the abstract method declaration in the base SQL adapter.
405 | *
406 | * @param string $id Attribute identifier
407 | * @return string Database-agnostic column description
408 | * @throws Exception
409 | */
410 | protected function getColumnDefinition(string $id): string
411 | {
412 | $attribute = $this->getAttribute($id);
413 |
414 | if (!$attribute) {
415 | throw new Exception("Attribute {$id} not found");
416 | }
417 |
418 | // For the Database adapter, we use Utopia's VAR_* type constants internally
419 | // This method provides a description for reference purposes
420 | /** @var string $type */
421 | $type = $attribute['type'];
422 | /** @var int $size */
423 | $size = $attribute['size'] ?? 0;
424 |
425 | if ($size > 0) {
426 | return "{$id}: {$type}({$size})";
427 | }
428 |
429 | return "{$id}: {$type}";
430 | }
431 | }
432 |
--------------------------------------------------------------------------------
/tests/Audit/AuditBase.php:
--------------------------------------------------------------------------------
1 | audit.
16 | */
17 | trait AuditBase
18 | {
19 | protected Audit $audit;
20 |
21 | /**
22 | * Classes using this trait must implement this to initialize the audit instance
23 | * with their specific adapter configuration
24 | */
25 | abstract protected function initializeAudit(): void;
26 |
27 | /**
28 | * Classes should override if they need custom setup
29 | */
30 | public function setUp(): void
31 | {
32 | $this->initializeAudit();
33 | $cleanup = new \DateTime();
34 | $cleanup = $cleanup->modify('+10 second');
35 | $this->audit->cleanup(new \DateTime());
36 | $this->createLogs();
37 | }
38 |
39 | /**
40 | * Classes should override if they need custom teardown
41 | */
42 | public function tearDown(): void
43 | {
44 | $cleanup = new \DateTime();
45 | $cleanup = $cleanup->modify('+10 second');
46 | $this->audit->cleanup(new \DateTime());
47 | }
48 |
49 | public function createLogs(): void
50 | {
51 | $userId = 'userId';
52 | $userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36';
53 | $ip = '127.0.0.1';
54 | $location = 'US';
55 | $data = ['key1' => 'value1', 'key2' => 'value2'];
56 |
57 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data));
58 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data));
59 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data));
60 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $location, $data));
61 | }
62 |
63 | public function testGetLogsByUser(): void
64 | {
65 | $logs = $this->audit->getLogsByUser('userId');
66 | $this->assertEquals(3, \count($logs));
67 |
68 | $logsCount = $this->audit->countLogsByUser('userId');
69 | $this->assertEquals(3, $logsCount);
70 |
71 | $logs1 = $this->audit->getLogsByUser('userId', limit: 1, offset: 1);
72 | $this->assertEquals(1, \count($logs1));
73 | $this->assertEquals($logs1[0]->getId(), $logs[1]->getId());
74 |
75 | $logs2 = $this->audit->getLogsByUser('userId', limit: 1, offset: 1);
76 | $this->assertEquals(1, \count($logs2));
77 | $this->assertEquals($logs2[0]->getId(), $logs[1]->getId());
78 | }
79 |
80 | public function testGetLogsByUserAndEvents(): void
81 | {
82 | $logs1 = $this->audit->getLogsByUserAndEvents('userId', ['update']);
83 | $logs2 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete']);
84 |
85 | $this->assertEquals(2, \count($logs1));
86 | $this->assertEquals(3, \count($logs2));
87 |
88 | $logsCount1 = $this->audit->countLogsByUserAndEvents('userId', ['update']);
89 | $logsCount2 = $this->audit->countLogsByUserAndEvents('userId', ['update', 'delete']);
90 |
91 | $this->assertEquals(2, $logsCount1);
92 | $this->assertEquals(3, $logsCount2);
93 |
94 | $logs3 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], limit: 1, offset: 1);
95 |
96 | $this->assertEquals(1, \count($logs3));
97 | $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId());
98 |
99 | $logs4 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], limit: 1, offset: 1);
100 |
101 | $this->assertEquals(1, \count($logs4));
102 | $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId());
103 | }
104 |
105 | public function testGetLogsByResourceAndEvents(): void
106 | {
107 | $logs1 = $this->audit->getLogsByResourceAndEvents('database/document/1', ['update']);
108 | $logs2 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete']);
109 |
110 | $this->assertEquals(1, \count($logs1));
111 | $this->assertEquals(2, \count($logs2));
112 |
113 | $logsCount1 = $this->audit->countLogsByResourceAndEvents('database/document/1', ['update']);
114 | $logsCount2 = $this->audit->countLogsByResourceAndEvents('database/document/2', ['update', 'delete']);
115 |
116 | $this->assertEquals(1, $logsCount1);
117 | $this->assertEquals(2, $logsCount2);
118 |
119 | $logs3 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], limit: 1, offset: 1);
120 |
121 | $this->assertEquals(1, \count($logs3));
122 | $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId());
123 |
124 | $logs4 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], limit: 1, offset: 1);
125 |
126 | $this->assertEquals(1, \count($logs4));
127 | $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId());
128 | }
129 |
130 | public function testGetLogsByResource(): void
131 | {
132 | $logs1 = $this->audit->getLogsByResource('database/document/1');
133 | $logs2 = $this->audit->getLogsByResource('database/document/2');
134 |
135 | $this->assertEquals(1, \count($logs1));
136 | $this->assertEquals(2, \count($logs2));
137 |
138 | $logsCount1 = $this->audit->countLogsByResource('database/document/1');
139 | $logsCount2 = $this->audit->countLogsByResource('database/document/2');
140 |
141 | $this->assertEquals(1, $logsCount1);
142 | $this->assertEquals(2, $logsCount2);
143 |
144 | $logs3 = $this->audit->getLogsByResource('database/document/2', limit: 1, offset: 1);
145 | $this->assertEquals(1, \count($logs3));
146 | $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId());
147 |
148 | $logs4 = $this->audit->getLogsByResource('database/document/2', limit: 1, offset: 1);
149 | $this->assertEquals(1, \count($logs4));
150 | $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId());
151 |
152 | $logs5 = $this->audit->getLogsByResource('user/null');
153 | $this->assertEquals(1, \count($logs5));
154 | $this->assertNull($logs5[0]['userId']);
155 | $this->assertEquals('127.0.0.1', $logs5[0]['ip']);
156 | }
157 |
158 | public function testLogByBatch(): void
159 | {
160 | // First cleanup existing logs
161 | $this->audit->cleanup(new \DateTime());
162 |
163 | $userId = 'batchUserId';
164 | $userAgent = 'Mozilla/5.0 (Test User Agent)';
165 | $ip = '192.168.1.1';
166 | $location = 'UK';
167 |
168 | // Create timestamps 1 minute apart
169 | $timestamp1 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -120)) ?? '';
170 | $timestamp2 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60)) ?? '';
171 | $timestamp3 = DateTime::formatTz(DateTime::now()) ?? '';
172 |
173 | $batchEvents = [
174 | [
175 | 'userId' => $userId,
176 | 'event' => 'create',
177 | 'resource' => 'database/document/batch1',
178 | 'userAgent' => $userAgent,
179 | 'ip' => $ip,
180 | 'location' => $location,
181 | 'data' => ['key' => 'value1'],
182 | 'time' => $timestamp1
183 | ],
184 | [
185 | 'userId' => $userId,
186 | 'event' => 'update',
187 | 'resource' => 'database/document/batch2',
188 | 'userAgent' => $userAgent,
189 | 'ip' => $ip,
190 | 'location' => $location,
191 | 'data' => ['key' => 'value2'],
192 | 'time' => $timestamp2
193 | ],
194 | [
195 | 'userId' => $userId,
196 | 'event' => 'delete',
197 | 'resource' => 'database/document/batch3',
198 | 'userAgent' => $userAgent,
199 | 'ip' => $ip,
200 | 'location' => $location,
201 | 'data' => ['key' => 'value3'],
202 | 'time' => $timestamp3
203 | ],
204 | [
205 | 'userId' => null,
206 | 'event' => 'insert',
207 | 'resource' => 'user1/null',
208 | 'userAgent' => $userAgent,
209 | 'ip' => $ip,
210 | 'location' => $location,
211 | 'data' => ['key' => 'value4'],
212 | 'time' => $timestamp3
213 | ]
214 | ];
215 |
216 | // Test batch insertion
217 | $result = $this->audit->logBatch($batchEvents);
218 | $this->assertIsArray($result);
219 | $this->assertEquals(4, count($result));
220 |
221 | // Verify the number of logs inserted
222 | $logs = $this->audit->getLogsByUser($userId);
223 | $this->assertEquals(3, count($logs));
224 |
225 | // Verify chronological order (newest first due to orderDesc)
226 | $this->assertEquals('delete', $logs[0]->getAttribute('event'));
227 | $this->assertEquals('update', $logs[1]->getAttribute('event'));
228 | $this->assertEquals('create', $logs[2]->getAttribute('event'));
229 |
230 | // Verify timestamps were preserved
231 | $this->assertEquals($timestamp3, $logs[0]->getAttribute('time'));
232 | $this->assertEquals($timestamp2, $logs[1]->getAttribute('time'));
233 | $this->assertEquals($timestamp1, $logs[2]->getAttribute('time'));
234 |
235 | // Test resource-based retrieval
236 | $resourceLogs = $this->audit->getLogsByResource('database/document/batch2');
237 | $this->assertEquals(1, count($resourceLogs));
238 | $this->assertEquals('update', $resourceLogs[0]->getAttribute('event'));
239 |
240 | // Test resource with userId null
241 | $resourceLogs = $this->audit->getLogsByResource('user1/null');
242 | $this->assertEquals(1, count($resourceLogs));
243 | foreach ($resourceLogs as $log) {
244 | $this->assertEquals('insert', $log->getAttribute('event'));
245 | $this->assertNull($log['userId']);
246 | }
247 |
248 | // Test event-based retrieval
249 | $eventLogs = $this->audit->getLogsByUserAndEvents($userId, ['create', 'delete']);
250 | $this->assertEquals(2, count($eventLogs));
251 | }
252 |
253 | public function testGetLogsCustomFilters(): void
254 | {
255 | $threshold = new \DateTime();
256 | $threshold->modify('-10 seconds');
257 | $logs = $this->audit->getLogsByUser('userId', after: $threshold);
258 |
259 | $this->assertEquals(3, \count($logs));
260 | }
261 |
262 | public function testAscendingOrderRetrieval(): void
263 | {
264 | // Test ascending order retrieval
265 | $logsDesc = $this->audit->getLogsByUser('userId', ascending: false);
266 | $logsAsc = $this->audit->getLogsByUser('userId', ascending: true);
267 |
268 | // Both should have same count
269 | $this->assertEquals(\count($logsDesc), \count($logsAsc));
270 |
271 | // Events should be in opposite order
272 | if (\count($logsDesc) > 1) {
273 | $descEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsDesc);
274 | $ascEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsAsc);
275 | $this->assertEquals($descEvents, array_reverse($ascEvents));
276 | }
277 | }
278 |
279 | public function testLargeBatchInsert(): void
280 | {
281 | // Create a large batch (50 events)
282 | $batchEvents = [];
283 | $baseTime = DateTime::now();
284 | for ($i = 0; $i < 50; $i++) {
285 | $batchEvents[] = [
286 | 'userId' => 'largebatchuser',
287 | 'event' => 'event_' . $i,
288 | 'resource' => 'doc/' . $i,
289 | 'userAgent' => 'Mozilla',
290 | 'ip' => '127.0.0.1',
291 | 'location' => 'US',
292 | 'data' => ['index' => $i],
293 | 'time' => DateTime::formatTz($baseTime) ?? ''
294 | ];
295 | }
296 |
297 | // Insert batch
298 | $result = $this->audit->logBatch($batchEvents);
299 | $this->assertEquals(50, \count($result));
300 |
301 | // Verify all were inserted
302 | $count = $this->audit->countLogsByUser('largebatchuser');
303 | $this->assertEquals(50, $count);
304 |
305 | // Test pagination
306 | $page1 = $this->audit->getLogsByUser('largebatchuser', limit: 10, offset: 0);
307 | $this->assertEquals(10, \count($page1));
308 |
309 | $page2 = $this->audit->getLogsByUser('largebatchuser', limit: 10, offset: 10);
310 | $this->assertEquals(10, \count($page2));
311 | }
312 |
313 | public function testTimeRangeFilters(): void
314 | {
315 | // Create logs with different timestamps
316 | $old = DateTime::format(new \DateTime('2024-01-01 10:00:00'));
317 | $recent = DateTime::now();
318 |
319 | $batchEvents = [
320 | [
321 | 'userId' => 'timerangeuser',
322 | 'event' => 'old_event',
323 | 'resource' => 'doc/1',
324 | 'userAgent' => 'Mozilla',
325 | 'ip' => '127.0.0.1',
326 | 'location' => 'US',
327 | 'data' => [],
328 | 'time' => $old
329 | ],
330 | [
331 | 'userId' => 'timerangeuser',
332 | 'event' => 'recent_event',
333 | 'resource' => 'doc/2',
334 | 'userAgent' => 'Mozilla',
335 | 'ip' => '127.0.0.1',
336 | 'location' => 'US',
337 | 'data' => [],
338 | 'time' => $recent
339 | ]
340 | ];
341 |
342 | $this->audit->logBatch($batchEvents);
343 |
344 | // Test getting all logs
345 | $all = $this->audit->getLogsByUser('timerangeuser');
346 | $this->assertGreaterThanOrEqual(2, \count($all));
347 |
348 | // Test with before filter - should get both since they're both in the past relative to future
349 | $beforeFuture = new \DateTime('2099-12-31 23:59:59');
350 | $beforeLogs = $this->audit->getLogsByUser('timerangeuser', before: $beforeFuture);
351 | $this->assertGreaterThanOrEqual(2, \count($beforeLogs));
352 | }
353 |
354 | public function testCleanup(): void
355 | {
356 | $status = $this->audit->cleanup(new \DateTime());
357 | $this->assertEquals($status, true);
358 |
359 | // Check that all logs have been deleted
360 | $logs = $this->audit->getLogsByUser('userId');
361 | $this->assertEquals(0, \count($logs));
362 |
363 | // Add three sample logs
364 | $userId = 'userId';
365 | $userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36';
366 | $ip = '127.0.0.1';
367 | $location = 'US';
368 | $data = ['key1' => 'value1', 'key2' => 'value2'];
369 |
370 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data));
371 | sleep(5);
372 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data));
373 | sleep(5);
374 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data));
375 | sleep(5);
376 |
377 | // DELETE logs older than 11 seconds and check that status is true
378 | $datetime = new \DateTime();
379 | $datetime->modify('-11 seconds');
380 | $status = $this->audit->cleanup($datetime);
381 | $this->assertEquals($status, true);
382 |
383 | // Check if 1 log has been deleted
384 | $logs = $this->audit->getLogsByUser('userId');
385 | $this->assertEquals(2, \count($logs));
386 | }
387 |
388 | /**
389 | * Test all additional retrieval parameters: limit, offset, ascending, after, before
390 | */
391 | public function testRetrievalParameters(): void
392 | {
393 | // Setup: Create logs with specific timestamps for testing
394 | $this->audit->cleanup(new \DateTime());
395 |
396 | $userId = 'paramtestuser';
397 | $userAgent = 'Mozilla/5.0';
398 | $ip = '192.168.1.1';
399 | $location = 'US';
400 |
401 | // Create 5 logs with different timestamps
402 | $baseTime = new \DateTime('2024-06-15 12:00:00');
403 | $batchEvents = [];
404 | for ($i = 0; $i < 5; $i++) {
405 | $offset = $i * 60;
406 | $logTime = new \DateTime('2024-06-15 12:00:00');
407 | $logTime->modify("+{$offset} seconds");
408 | $timestamp = DateTime::format($logTime);
409 | $batchEvents[] = [
410 | 'userId' => $userId,
411 | 'event' => 'event_' . $i,
412 | 'resource' => 'doc/' . $i,
413 | 'userAgent' => $userAgent,
414 | 'ip' => $ip,
415 | 'location' => $location,
416 | 'data' => ['sequence' => $i],
417 | 'time' => $timestamp
418 | ];
419 | }
420 |
421 | $this->audit->logBatch($batchEvents);
422 |
423 | // Test 1: limit parameter
424 | $logsLimit2 = $this->audit->getLogsByUser($userId, limit: 2);
425 | $this->assertEquals(2, \count($logsLimit2));
426 |
427 | $logsLimit3 = $this->audit->getLogsByUser($userId, limit: 3);
428 | $this->assertEquals(3, \count($logsLimit3));
429 |
430 | // Test 2: offset parameter
431 | $logsOffset0 = $this->audit->getLogsByUser($userId, limit: 10, offset: 0);
432 | $logsOffset2 = $this->audit->getLogsByUser($userId, limit: 10, offset: 2);
433 | $logsOffset4 = $this->audit->getLogsByUser($userId, limit: 10, offset: 4);
434 |
435 | $this->assertEquals(5, \count($logsOffset0));
436 | $this->assertEquals(3, \count($logsOffset2));
437 | $this->assertEquals(1, \count($logsOffset4));
438 |
439 | // Verify offset returns different logs
440 | $this->assertNotEquals($logsOffset0[0]->getId(), $logsOffset2[0]->getId());
441 | $this->assertNotEquals($logsOffset2[0]->getId(), $logsOffset4[0]->getId());
442 |
443 | // Test 3: ascending parameter
444 | $logsDesc = $this->audit->getLogsByUser($userId, ascending: false);
445 | $logsAsc = $this->audit->getLogsByUser($userId, ascending: true);
446 |
447 | $this->assertEquals(5, \count($logsDesc));
448 | $this->assertEquals(5, \count($logsAsc));
449 |
450 | // Verify order is reversed
451 | if (\count($logsDesc) === \count($logsAsc)) {
452 | for ($i = 0; $i < \count($logsDesc); $i++) {
453 | $this->assertEquals(
454 | $logsDesc[$i]->getId(),
455 | $logsAsc[\count($logsAsc) - 1 - $i]->getId()
456 | );
457 | }
458 | }
459 |
460 | // Test 4: after parameter (logs after a certain timestamp)
461 | $afterTimeObj = new \DateTime('2024-06-15 12:03:00'); // After 3rd log
462 | $logsAfter = $this->audit->getLogsByUser($userId, after: $afterTimeObj);
463 | // Should get logs at positions 3 and 4 (2 logs)
464 | $this->assertGreaterThanOrEqual(1, \count($logsAfter));
465 |
466 | // Test 5: before parameter (logs before a certain timestamp)
467 | $beforeTimeObj = new \DateTime('2024-06-15 12:02:00'); // Before 3rd log
468 | $logsBefore = $this->audit->getLogsByUser($userId, before: $beforeTimeObj);
469 | // Should get logs at positions 0, 1, 2 (3 logs)
470 | $this->assertGreaterThanOrEqual(1, \count($logsBefore));
471 |
472 | // Test 6: Combination of limit + offset
473 | $logsPage1 = $this->audit->getLogsByUser($userId, limit: 2, offset: 0);
474 | $logsPage2 = $this->audit->getLogsByUser($userId, limit: 2, offset: 2);
475 | $logsPage3 = $this->audit->getLogsByUser($userId, limit: 2, offset: 4);
476 |
477 | $this->assertEquals(2, \count($logsPage1));
478 | $this->assertEquals(2, \count($logsPage2));
479 | $this->assertEquals(1, \count($logsPage3));
480 |
481 | // Verify pages don't overlap
482 | $this->assertNotEquals($logsPage1[0]->getId(), $logsPage2[0]->getId());
483 | $this->assertNotEquals($logsPage2[0]->getId(), $logsPage3[0]->getId());
484 |
485 | // Test 7: Combination of ascending + limit
486 | $ascLimit2 = $this->audit->getLogsByUser($userId, limit: 2, ascending: true);
487 | $this->assertEquals(2, \count($ascLimit2));
488 | // First log should be oldest in ascending order
489 | $this->assertEquals('event_0', $ascLimit2[0]->getAttribute('event'));
490 |
491 | // Test 8: Combination of after + before (time range)
492 | $afterTimeObj2 = new \DateTime('2024-06-15 12:01:00'); // After 1st log
493 | $beforeTimeObj2 = new \DateTime('2024-06-15 12:04:00'); // Before 4th log
494 | $logsRange = $this->audit->getLogsByUser($userId, after: $afterTimeObj2, before: $beforeTimeObj2);
495 | $this->assertGreaterThanOrEqual(1, \count($logsRange));
496 |
497 | // Test 9: Test with getLogsByResource using parameters
498 | $logsRes = $this->audit->getLogsByResource('doc/0', limit: 1, offset: 0);
499 | $this->assertEquals(1, \count($logsRes));
500 |
501 | // Test 10: Test with getLogsByUserAndEvents using parameters
502 | $logsEvt = $this->audit->getLogsByUserAndEvents(
503 | $userId,
504 | ['event_1', 'event_2'],
505 | limit: 1,
506 | offset: 0,
507 | ascending: false
508 | );
509 | $this->assertGreaterThanOrEqual(0, \count($logsEvt));
510 |
511 | // Test 11: Test count methods with after/before filters
512 | $countAll = $this->audit->countLogsByUser($userId);
513 | $this->assertEquals(5, $countAll);
514 |
515 | $countAfter = $this->audit->countLogsByUser($userId, after: $afterTimeObj);
516 | $this->assertGreaterThanOrEqual(0, $countAfter);
517 |
518 | $countBefore = $this->audit->countLogsByUser($userId, before: $beforeTimeObj);
519 | $this->assertGreaterThanOrEqual(0, $countBefore);
520 |
521 | // Test 12: Test countLogsByResource with filters
522 | $countResAll = $this->audit->countLogsByResource('doc/0');
523 | $this->assertEquals(1, $countResAll);
524 |
525 | $countResAfter = $this->audit->countLogsByResource('doc/0', after: $afterTimeObj);
526 | $this->assertGreaterThanOrEqual(0, $countResAfter);
527 |
528 | // Test 13: Test countLogsByUserAndEvents with filters
529 | $countEvtAll = $this->audit->countLogsByUserAndEvents($userId, ['event_1', 'event_2']);
530 | $this->assertGreaterThanOrEqual(0, $countEvtAll);
531 |
532 | $countEvtAfter = $this->audit->countLogsByUserAndEvents(
533 | $userId,
534 | ['event_1', 'event_2'],
535 | after: $afterTimeObj
536 | );
537 | $this->assertGreaterThanOrEqual(0, $countEvtAfter);
538 |
539 | // Test 14: Test countLogsByResourceAndEvents with filters
540 | $countResEvtAll = $this->audit->countLogsByResourceAndEvents('doc/0', ['event_0']);
541 | $this->assertEquals(1, $countResEvtAll);
542 |
543 | $countResEvtAfter = $this->audit->countLogsByResourceAndEvents(
544 | 'doc/0',
545 | ['event_0'],
546 | after: $afterTimeObj
547 | );
548 | $this->assertGreaterThanOrEqual(0, $countResEvtAfter);
549 |
550 | // Test 15: Test getLogsByResourceAndEvents with all parameters
551 | $logsResEvt = $this->audit->getLogsByResourceAndEvents(
552 | 'doc/1',
553 | ['event_1'],
554 | limit: 1,
555 | offset: 0,
556 | ascending: true
557 | );
558 | $this->assertGreaterThanOrEqual(0, \count($logsResEvt));
559 | }
560 | }
561 |
--------------------------------------------------------------------------------
/src/Audit/Adapter/ClickHouse.php:
--------------------------------------------------------------------------------
1 | validateHost($host);
63 | $this->validatePort($port);
64 |
65 | $this->host = $host;
66 | $this->port = $port;
67 | $this->username = $username;
68 | $this->password = $password;
69 | $this->secure = $secure;
70 |
71 | // Initialize the HTTP client for connection reuse
72 | $this->client = new Client();
73 | $this->client->addHeader('X-ClickHouse-User', $this->username);
74 | $this->client->addHeader('X-ClickHouse-Key', $this->password);
75 | $this->client->setTimeout(30);
76 | }
77 |
78 | /**
79 | * Get adapter name.
80 | */
81 | public function getName(): string
82 | {
83 | return 'ClickHouse';
84 | }
85 |
86 | /**
87 | * Validate host parameter.
88 | *
89 | * @param string $host
90 | * @throws Exception
91 | */
92 | private function validateHost(string $host): void
93 | {
94 | $validator = new Hostname();
95 | if (!$validator->isValid($host)) {
96 | throw new Exception('ClickHouse host is not a valid hostname or IP address');
97 | }
98 | }
99 |
100 | /**
101 | * Validate port parameter.
102 | *
103 | * @param int $port
104 | * @throws Exception
105 | */
106 | private function validatePort(int $port): void
107 | {
108 | if ($port < 1 || $port > 65535) {
109 | throw new Exception('ClickHouse port must be between 1 and 65535');
110 | }
111 | }
112 |
113 | /**
114 | * Validate identifier (database, table, namespace).
115 | * ClickHouse identifiers follow SQL standard rules.
116 | *
117 | * @param string $identifier
118 | * @param string $type Name of the identifier type for error messages
119 | * @throws Exception
120 | */
121 | private function validateIdentifier(string $identifier, string $type = 'Identifier'): void
122 | {
123 | if (empty($identifier)) {
124 | throw new Exception("{$type} cannot be empty");
125 | }
126 |
127 | if (strlen($identifier) > 255) {
128 | throw new Exception("{$type} cannot exceed 255 characters");
129 | }
130 |
131 | // ClickHouse identifiers: alphanumeric, underscores, cannot start with number
132 | if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $identifier)) {
133 | throw new Exception("{$type} must start with a letter or underscore and contain only alphanumeric characters and underscores");
134 | }
135 |
136 | // Check against SQL keywords (common ones)
137 | $keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TABLE', 'DATABASE'];
138 | if (in_array(strtoupper($identifier), $keywords, true)) {
139 | throw new Exception("{$type} cannot be a reserved SQL keyword");
140 | }
141 | }
142 |
143 | /**
144 | * Escape an identifier (database name, table name, column name) for safe use in SQL.
145 | * Uses backticks as per SQL standard for identifier quoting.
146 | *
147 | * @param string $identifier
148 | * @return string
149 | */
150 | private function escapeIdentifier(string $identifier): string
151 | {
152 | // Backtick escaping: replace any backticks in the identifier with double backticks
153 | return '`' . str_replace('`', '``', $identifier) . '`';
154 | }
155 | /**
156 | * Set the namespace for multi-project support.
157 | * Namespace is used as a prefix for table names.
158 | *
159 | * @param string $namespace
160 | * @return self
161 | * @throws Exception
162 | */
163 | public function setNamespace(string $namespace): self
164 | {
165 | if (!empty($namespace)) {
166 | $this->validateIdentifier($namespace, 'Namespace');
167 | }
168 | $this->namespace = $namespace;
169 | return $this;
170 | }
171 |
172 | /**
173 | * Set the database name for subsequent operations.
174 | *
175 | * @param string $database
176 | * @return self
177 | * @throws Exception
178 | */
179 | public function setDatabase(string $database): self
180 | {
181 | $this->validateIdentifier($database, 'Database');
182 | $this->database = $database;
183 | return $this;
184 | }
185 |
186 | /**
187 | * Enable or disable HTTPS for ClickHouse HTTP interface.
188 | */
189 | public function setSecure(bool $secure): self
190 | {
191 | $this->secure = $secure;
192 | return $this;
193 | }
194 |
195 | /**
196 | * Get the namespace.
197 | *
198 | * @return string
199 | */
200 | public function getNamespace(): string
201 | {
202 | return $this->namespace;
203 | }
204 |
205 | /**
206 | * Set the tenant ID for multi-tenant support.
207 | * Tenant is used to isolate audit logs by tenant.
208 | *
209 | * @param int|null $tenant
210 | * @return self
211 | */
212 | public function setTenant(?int $tenant): self
213 | {
214 | $this->tenant = $tenant;
215 | return $this;
216 | }
217 |
218 | /**
219 | * Get the tenant ID.
220 | *
221 | * @return int|null
222 | */
223 | public function getTenant(): ?int
224 | {
225 | return $this->tenant;
226 | }
227 |
228 | /**
229 | * Set whether tables are shared across tenants.
230 | * When enabled, a tenant column is added to the table for data isolation.
231 | *
232 | * @param bool $sharedTables
233 | * @return self
234 | */
235 | public function setSharedTables(bool $sharedTables): self
236 | {
237 | $this->sharedTables = $sharedTables;
238 | return $this;
239 | }
240 |
241 | /**
242 | * Get whether tables are shared across tenants.
243 | *
244 | * @return bool
245 | */
246 | public function isSharedTables(): bool
247 | {
248 | return $this->sharedTables;
249 | }
250 |
251 | /**
252 | * Get the table name with namespace prefix.
253 | * Namespace is used to isolate tables for different projects/applications.
254 | *
255 | * @return string
256 | */
257 | private function getTableName(): string
258 | {
259 | $tableName = $this->table;
260 |
261 | if (!empty($this->namespace)) {
262 | $tableName = $this->namespace . '_' . $tableName;
263 | }
264 |
265 | return $tableName;
266 | }
267 |
268 | /**
269 | * Execute a ClickHouse query via HTTP interface using Fetch Client.
270 | *
271 | * Uses ClickHouse query parameters (sent as POST multipart form data) to prevent SQL injection.
272 | * This is ClickHouse's native parameter mechanism - parameters are safely
273 | * transmitted separately from the query structure.
274 | *
275 | * Parameters are referenced in the SQL using the syntax: {paramName:Type}.
276 | * For example: SELECT * WHERE id = {id:String}
277 | *
278 | * ClickHouse handles all parameter escaping and type conversion internally,
279 | * making this approach fully injection-safe without needing manual escaping.
280 | *
281 | * Using POST body avoids URL length limits for batch operations with many parameters.
282 | * Equivalent to: curl -X POST -F 'query=...' -F 'param_key=value' http://host/
283 | *
284 | * @param array $params Key-value pairs for query parameters
285 | * @throws Exception
286 | */
287 | private function query(string $sql, array $params = []): string
288 | {
289 | $scheme = $this->secure ? 'https' : 'http';
290 | $url = "{$scheme}://{$this->host}:{$this->port}/";
291 |
292 | // Update the database header for each query (in case setDatabase was called)
293 | $this->client->addHeader('X-ClickHouse-Database', $this->database);
294 |
295 | // Build multipart form data body with query and parameters
296 | // The Fetch client will automatically encode arrays as multipart/form-data
297 | $body = ['query' => $sql];
298 | foreach ($params as $key => $value) {
299 | $body['param_' . $key] = $this->formatParamValue($value);
300 | }
301 |
302 | try {
303 | $response = $this->client->fetch(
304 | url: $url,
305 | method: Client::METHOD_POST,
306 | body: $body
307 | );
308 | if ($response->getStatusCode() !== 200) {
309 | $bodyStr = $response->getBody();
310 | $bodyStr = is_string($bodyStr) ? $bodyStr : '';
311 | throw new Exception("ClickHouse query failed with HTTP {$response->getStatusCode()}: {$bodyStr}");
312 | }
313 |
314 | $body = $response->getBody();
315 | return is_string($body) ? $body : '';
316 | } catch (Exception $e) {
317 | // Preserve the original exception context for better debugging
318 | // Re-throw with additional context while maintaining the original exception chain
319 | throw new Exception(
320 | "ClickHouse query execution failed: {$e->getMessage()}",
321 | 0,
322 | $e
323 | );
324 | }
325 | }
326 |
327 | /**
328 | * Format a parameter value for safe transmission to ClickHouse.
329 | *
330 | * Converts PHP values to their string representation without SQL quoting.
331 | * ClickHouse's query parameter mechanism handles type conversion and escaping.
332 | *
333 | * @param mixed $value
334 | * @return string
335 | */
336 | private function formatParamValue(mixed $value): string
337 | {
338 | if (is_int($value) || is_float($value)) {
339 | return (string) $value;
340 | }
341 |
342 | if ($value === null) {
343 | return '';
344 | }
345 |
346 | if (is_bool($value)) {
347 | return $value ? '1' : '0';
348 | }
349 |
350 | if (is_array($value)) {
351 | $encoded = json_encode($value);
352 | return is_string($encoded) ? $encoded : '';
353 | }
354 |
355 | if (is_string($value)) {
356 | return $value;
357 | }
358 |
359 | // For objects or other types, attempt to convert to string
360 | if (is_object($value) && method_exists($value, '__toString')) {
361 | return (string) $value;
362 | }
363 |
364 | return '';
365 | }
366 |
367 | /**
368 | * Setup ClickHouse table structure.
369 | *
370 | * Creates the database and table if they don't exist.
371 | * Uses schema definitions from the base SQL adapter.
372 | *
373 | * @throws Exception
374 | */
375 | public function setup(): void
376 | {
377 | // Create database if not exists
378 | $escapedDatabase = $this->escapeIdentifier($this->database);
379 | $createDbSql = "CREATE DATABASE IF NOT EXISTS {$escapedDatabase}";
380 | $this->query($createDbSql);
381 |
382 | // Build column definitions from base adapter schema
383 | // Override time column to be NOT NULL since it's used in partition key
384 | $columns = [
385 | 'id String',
386 | ];
387 |
388 | foreach ($this->getAttributes() as $attribute) {
389 | /** @var string $id */
390 | $id = $attribute['$id'];
391 |
392 | // Special handling for time column - must be NOT NULL for partition key
393 | if ($id === 'time') {
394 | $columns[] = 'time DateTime64(3)';
395 | } else {
396 | $columns[] = $this->getColumnDefinition($id);
397 | }
398 | }
399 |
400 | // Add tenant column only if tables are shared across tenants
401 | if ($this->sharedTables) {
402 | $columns[] = 'tenant Nullable(UInt64)'; // Supports 11-digit MySQL auto-increment IDs
403 | }
404 |
405 | // Build indexes from base adapter schema
406 | $indexes = [];
407 | foreach ($this->getIndexes() as $index) {
408 | /** @var string $indexName */
409 | $indexName = $index['$id'];
410 | /** @var array $attributes */
411 | $attributes = $index['attributes'];
412 | $attributeList = implode(', ', $attributes);
413 | $indexes[] = "INDEX {$indexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1";
414 | }
415 |
416 | $tableName = $this->getTableName();
417 | $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
418 |
419 | // Create table with MergeTree engine for optimal performance
420 | $createTableSql = "
421 | CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} (
422 | " . implode(",\n ", $columns) . ",
423 | " . implode(",\n ", $indexes) . "
424 | )
425 | ENGINE = MergeTree()
426 | ORDER BY (time, id)
427 | PARTITION BY toYYYYMM(time)
428 | SETTINGS index_granularity = 8192
429 | ";
430 |
431 | $this->query($createTableSql);
432 | }
433 |
434 | /**
435 | * Create an audit log entry.
436 | *
437 | * @throws Exception
438 | */
439 | public function create(array $log): Log
440 | {
441 | $id = uniqid('', true);
442 | $time = (new \DateTime())->format('Y-m-d H:i:s.v');
443 |
444 | $tableName = $this->getTableName();
445 |
446 | // Build column list and values based on sharedTables setting
447 | $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data'];
448 | $placeholders = ['{id:String}', '{userId:Nullable(String)}', '{event:String}', '{resource:String}', '{userAgent:String}', '{ip:String}', '{location:Nullable(String)}', '{time:String}', '{data:String}'];
449 |
450 | $params = [
451 | 'id' => $id,
452 | 'userId' => $log['userId'] ?? null,
453 | 'event' => $log['event'],
454 | 'resource' => $log['resource'],
455 | 'userAgent' => $log['userAgent'],
456 | 'ip' => $log['ip'],
457 | 'location' => $log['location'] ?? null,
458 | 'time' => $time,
459 | 'data' => json_encode($log['data'] ?? []),
460 | ];
461 |
462 | if ($this->sharedTables) {
463 | $columns[] = 'tenant';
464 | $placeholders[] = '{tenant:Nullable(UInt64)}';
465 | $params['tenant'] = $this->tenant;
466 | }
467 |
468 | $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
469 | $insertSql = "
470 | INSERT INTO {$escapedDatabaseAndTable}
471 | (" . implode(', ', $columns) . ")
472 | VALUES (
473 | " . implode(", ", $placeholders) . "
474 | )
475 | ";
476 |
477 | $this->query($insertSql, $params);
478 |
479 | $result = [
480 | '$id' => $id,
481 | 'userId' => $log['userId'] ?? null,
482 | 'event' => $log['event'],
483 | 'resource' => $log['resource'],
484 | 'userAgent' => $log['userAgent'],
485 | 'ip' => $log['ip'],
486 | 'location' => $log['location'] ?? null,
487 | 'time' => $time,
488 | 'data' => $log['data'] ?? [],
489 | ];
490 |
491 | if ($this->sharedTables) {
492 | $result['tenant'] = $this->tenant;
493 | }
494 |
495 | return new Log($result);
496 | }
497 |
498 | /**
499 | * Create multiple audit log entries in batch.
500 | *
501 | * @throws Exception
502 | */
503 | public function createBatch(array $logs): array
504 | {
505 | if (empty($logs)) {
506 | return [];
507 | }
508 |
509 | $tableName = $this->getTableName();
510 | $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
511 |
512 | // Build column list based on sharedTables setting
513 | $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data'];
514 | if ($this->sharedTables) {
515 | $columns[] = 'tenant';
516 | }
517 |
518 | $ids = [];
519 | $paramCounter = 0;
520 | $params = [];
521 | $valueClauses = [];
522 |
523 | foreach ($logs as $log) {
524 | $id = uniqid('', true);
525 | $ids[] = $id;
526 |
527 | // Create parameter placeholders for this row
528 | $paramKeys = [];
529 | $paramKeys[] = 'id_' . $paramCounter;
530 | $paramKeys[] = 'userId_' . $paramCounter;
531 | $paramKeys[] = 'event_' . $paramCounter;
532 | $paramKeys[] = 'resource_' . $paramCounter;
533 | $paramKeys[] = 'userAgent_' . $paramCounter;
534 | $paramKeys[] = 'ip_' . $paramCounter;
535 | $paramKeys[] = 'location_' . $paramCounter;
536 | $paramKeys[] = 'time_' . $paramCounter;
537 | $paramKeys[] = 'data_' . $paramCounter;
538 |
539 | // Set parameter values
540 | $params[$paramKeys[0]] = $id;
541 | $params[$paramKeys[1]] = $log['userId'] ?? null;
542 | $params[$paramKeys[2]] = $log['event'];
543 | $params[$paramKeys[3]] = $log['resource'];
544 | $params[$paramKeys[4]] = $log['userAgent'];
545 | $params[$paramKeys[5]] = $log['ip'];
546 | $params[$paramKeys[6]] = $log['location'] ?? null;
547 |
548 | $time = $log['time'] ?? new \DateTime();
549 | if (is_string($time)) {
550 | $time = new \DateTime($time);
551 | }
552 | $params[$paramKeys[7]] = $time->format('Y-m-d H:i:s.v');
553 | $params[$paramKeys[8]] = json_encode($log['data'] ?? []);
554 |
555 | if ($this->sharedTables) {
556 | $paramKeys[] = 'tenant_' . $paramCounter;
557 | $params[$paramKeys[9]] = $this->tenant;
558 | }
559 |
560 | // Build placeholder string for this row
561 | $placeholders = [];
562 | for ($i = 0; $i < count($paramKeys); $i++) {
563 | if ($i === 1 || $i === 6) { // userId and location are nullable
564 | $placeholders[] = '{' . $paramKeys[$i] . ':Nullable(String)}';
565 | } elseif ($this->sharedTables && $i === 9) { // tenant is nullable UInt64
566 | $placeholders[] = '{' . $paramKeys[$i] . ':Nullable(UInt64)}';
567 | } else {
568 | $placeholders[] = '{' . $paramKeys[$i] . ':String}';
569 | }
570 | }
571 |
572 | $valueClauses[] = '(' . implode(', ', $placeholders) . ')';
573 | $paramCounter++;
574 | }
575 |
576 | $insertSql = "
577 | INSERT INTO {$escapedDatabaseAndTable}
578 | (" . implode(', ', $columns) . ")
579 | VALUES " . implode(', ', $valueClauses);
580 |
581 | $this->query($insertSql, $params);
582 |
583 | // Return documents using the same IDs that were inserted
584 | $documents = [];
585 | foreach ($logs as $index => $log) {
586 | $result = [
587 | '$id' => $ids[$index],
588 | 'userId' => $log['userId'] ?? null,
589 | 'event' => $log['event'],
590 | 'resource' => $log['resource'],
591 | 'userAgent' => $log['userAgent'],
592 | 'ip' => $log['ip'],
593 | 'location' => $log['location'] ?? null,
594 | 'time' => $log['time'],
595 | 'data' => $log['data'] ?? [],
596 | ];
597 |
598 | if ($this->sharedTables) {
599 | $result['tenant'] = $this->tenant;
600 | }
601 |
602 | $documents[] = new Log($result);
603 | }
604 |
605 | return $documents;
606 | }
607 |
608 | /**
609 | * Parse ClickHouse query result into Log objects.
610 | *
611 | * @return array
612 | */
613 | private function parseResults(string $result): array
614 | {
615 | if (empty(trim($result))) {
616 | return [];
617 | }
618 |
619 | $lines = explode("\n", trim($result));
620 | $documents = [];
621 |
622 | foreach ($lines as $line) {
623 | if (empty(trim($line))) {
624 | continue;
625 | }
626 |
627 | $columns = explode("\t", $line);
628 | // Expect 9 columns without sharedTables, 10 with sharedTables
629 | $expectedColumns = $this->sharedTables ? 10 : 9;
630 | if (count($columns) < $expectedColumns) {
631 | continue;
632 | }
633 |
634 | $data = json_decode($columns[8], true) ?? [];
635 |
636 | // Convert ClickHouse timestamp format back to ISO 8601
637 | // ClickHouse: 2025-12-07 23:33:54.493
638 | // ISO 8601: 2025-12-07T23:33:54.493+00:00
639 | $time = $columns[7];
640 | if (strpos($time, 'T') === false) {
641 | $time = str_replace(' ', 'T', $time) . '+00:00';
642 | }
643 |
644 | // Helper function to parse nullable string fields
645 | // ClickHouse TabSeparated format uses \N for NULL, but empty strings are also treated as null for nullable fields
646 | $parseNullableString = static function ($value): ?string {
647 | if ($value === '\\N' || $value === '') {
648 | return null;
649 | }
650 | return $value;
651 | };
652 |
653 | $document = [
654 | '$id' => $columns[0],
655 | 'userId' => $parseNullableString($columns[1]),
656 | 'event' => $columns[2],
657 | 'resource' => $columns[3],
658 | 'userAgent' => $columns[4],
659 | 'ip' => $columns[5],
660 | 'location' => $parseNullableString($columns[6]),
661 | 'time' => $time,
662 | 'data' => $data,
663 | ];
664 |
665 | // Add tenant only if sharedTables is enabled
666 | if ($this->sharedTables && isset($columns[9])) {
667 | $document['tenant'] = $columns[9] === '\\N' || $columns[9] === '' ? null : (int) $columns[9];
668 | }
669 |
670 | $documents[] = new Log($document);
671 | }
672 |
673 | return $documents;
674 | }
675 |
676 | /**
677 | * Get the SELECT column list for queries.
678 | * Returns 9 columns if not using shared tables, 10 if using shared tables.
679 | *
680 | * @return string
681 | */
682 | private function getSelectColumns(): string
683 | {
684 | if ($this->sharedTables) {
685 | return 'id, userId, event, resource, userAgent, ip, location, time, data, tenant';
686 | }
687 | return 'id, userId, event, resource, userAgent, ip, location, time, data';
688 | }
689 |
690 | /**
691 | * Build tenant filter clause based on current tenant context.
692 | *
693 | * @return string
694 | */
695 | private function getTenantFilter(): string
696 | {
697 | if (!$this->sharedTables || $this->tenant === null) {
698 | return '';
699 | }
700 |
701 | return " AND tenant = {$this->tenant}";
702 | }
703 |
704 | /**
705 | * Build time WHERE clause and parameters with safe parameter placeholders.
706 | *
707 | * @param \DateTime|null $after
708 | * @param \DateTime|null $before
709 | * @return array{clause: string, params: array}
710 | */
711 | private function buildTimeClause(?\DateTime $after, ?\DateTime $before): array
712 | {
713 | $params = [];
714 | $conditions = [];
715 |
716 | $afterStr = null;
717 | $beforeStr = null;
718 |
719 | if ($after !== null) {
720 | /** @var \DateTime $after */
721 | $afterStr = \Utopia\Database\DateTime::format($after);
722 | }
723 |
724 | if ($before !== null) {
725 | /** @var \DateTime $before */
726 | $beforeStr = \Utopia\Database\DateTime::format($before);
727 | }
728 |
729 | if ($afterStr !== null && $beforeStr !== null) {
730 | $conditions[] = 'time BETWEEN {after:String} AND {before:String}';
731 | $params['after'] = $afterStr;
732 | $params['before'] = $beforeStr;
733 |
734 | return ['clause' => ' AND ' . $conditions[0], 'params' => $params];
735 | }
736 |
737 | if ($afterStr !== null) {
738 | $conditions[] = 'time > {after:String}';
739 | $params['after'] = $afterStr;
740 | }
741 |
742 | if ($beforeStr !== null) {
743 | $conditions[] = 'time < {before:String}';
744 | $params['before'] = $beforeStr;
745 | }
746 |
747 | if ($conditions === []) {
748 | return ['clause' => '', 'params' => []];
749 | }
750 |
751 | return [
752 | 'clause' => ' AND ' . implode(' AND ', $conditions),
753 | 'params' => $params,
754 | ];
755 | }
756 |
757 | /**
758 | * Build a formatted SQL IN list from an array of events.
759 | * Events are parameterized for safe SQL inclusion.
760 | *
761 | * @param array $events
762 | * @param int $paramOffset Base parameter number for creating unique param names
763 | * @return array{clause: string, params: array}
764 | */
765 | private function buildEventsList(array $events, int $paramOffset = 0): array
766 | {
767 | $placeholders = [];
768 | $params = [];
769 |
770 | foreach ($events as $index => $event) {
771 | /** @var int $paramNumber */
772 | $paramNumber = $paramOffset + (int) $index;
773 | $paramName = 'event_' . (string) $paramNumber;
774 | $placeholders[] = '{' . $paramName . ':String}';
775 | $params[$paramName] = $event;
776 | }
777 |
778 | $clause = implode(', ', $placeholders);
779 | return ['clause' => $clause, 'params' => $params];
780 | }
781 |
782 | /**
783 | * Get ClickHouse-specific SQL column definition for a given attribute ID.
784 | *
785 | * @param string $id Attribute identifier
786 | * @return string ClickHouse column definition with appropriate types and nullability
787 | * @throws Exception
788 | */
789 | protected function getColumnDefinition(string $id): string
790 | {
791 | $attribute = $this->getAttribute($id);
792 |
793 | if (!$attribute) {
794 | throw new Exception("Attribute {$id} not found");
795 | }
796 |
797 | // ClickHouse-specific type mapping
798 | $type = match ($id) {
799 | 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'data' => 'String',
800 | 'time' => 'DateTime64(3)',
801 | default => 'String',
802 | };
803 |
804 | $nullable = !$attribute['required'] ? 'Nullable(' . $type . ')' : $type;
805 |
806 | return "{$id} {$nullable}";
807 | }
808 |
809 | /**
810 | * Get logs by user ID.
811 | *
812 | * @throws Exception
813 | */
814 | public function getByUser(
815 | string $userId,
816 | ?\DateTime $after = null,
817 | ?\DateTime $before = null,
818 | int $limit = 25,
819 | int $offset = 0,
820 | bool $ascending = false,
821 | ): array {
822 | $time = $this->buildTimeClause($after, $before);
823 | $order = $ascending ? 'ASC' : 'DESC';
824 |
825 | $tableName = $this->getTableName();
826 | $tenantFilter = $this->getTenantFilter();
827 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
828 |
829 | $sql = "
830 | SELECT " . $this->getSelectColumns() . "
831 | FROM {$escapedTable}
832 | WHERE userId = {userId:String}{$tenantFilter}{$time['clause']}
833 | ORDER BY time {$order}
834 | LIMIT {limit:UInt64} OFFSET {offset:UInt64}
835 | FORMAT TabSeparated
836 | ";
837 |
838 | $result = $this->query($sql, array_merge([
839 | 'userId' => $userId,
840 | 'limit' => $limit,
841 | 'offset' => $offset,
842 | ], $time['params']));
843 |
844 | return $this->parseResults($result);
845 | }
846 |
847 | /**
848 | * Count logs by user ID.
849 | *
850 | * @throws Exception
851 | */
852 | public function countByUser(
853 | string $userId,
854 | ?\DateTime $after = null,
855 | ?\DateTime $before = null,
856 | ): int {
857 | $time = $this->buildTimeClause($after, $before);
858 |
859 | $tableName = $this->getTableName();
860 | $tenantFilter = $this->getTenantFilter();
861 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
862 |
863 | $sql = "
864 | SELECT count()
865 | FROM {$escapedTable}
866 | WHERE userId = {userId:String}{$tenantFilter}{$time['clause']}
867 | FORMAT TabSeparated
868 | ";
869 |
870 | $result = $this->query($sql, array_merge([
871 | 'userId' => $userId,
872 | ], $time['params']));
873 |
874 | return (int) trim($result);
875 | }
876 |
877 | /**
878 | * Get logs by resource.
879 | *
880 | * @throws Exception
881 | */
882 | public function getByResource(
883 | string $resource,
884 | ?\DateTime $after = null,
885 | ?\DateTime $before = null,
886 | int $limit = 25,
887 | int $offset = 0,
888 | bool $ascending = false,
889 | ): array {
890 | $time = $this->buildTimeClause($after, $before);
891 | $order = $ascending ? 'ASC' : 'DESC';
892 |
893 | $tableName = $this->getTableName();
894 | $tenantFilter = $this->getTenantFilter();
895 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
896 |
897 | $sql = "
898 | SELECT " . $this->getSelectColumns() . "
899 | FROM {$escapedTable}
900 | WHERE resource = {resource:String}{$tenantFilter}{$time['clause']}
901 | ORDER BY time {$order}
902 | LIMIT {limit:UInt64} OFFSET {offset:UInt64}
903 | FORMAT TabSeparated
904 | ";
905 |
906 | $result = $this->query($sql, array_merge([
907 | 'resource' => $resource,
908 | 'limit' => $limit,
909 | 'offset' => $offset,
910 | ], $time['params']));
911 |
912 | return $this->parseResults($result);
913 | }
914 |
915 | /**
916 | * Count logs by resource.
917 | *
918 | * @throws Exception
919 | */
920 | public function countByResource(
921 | string $resource,
922 | ?\DateTime $after = null,
923 | ?\DateTime $before = null,
924 | ): int {
925 | $time = $this->buildTimeClause($after, $before);
926 |
927 | $tableName = $this->getTableName();
928 | $tenantFilter = $this->getTenantFilter();
929 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
930 |
931 | $sql = "
932 | SELECT count()
933 | FROM {$escapedTable}
934 | WHERE resource = {resource:String}{$tenantFilter}{$time['clause']}
935 | FORMAT TabSeparated
936 | ";
937 |
938 | $result = $this->query($sql, array_merge([
939 | 'resource' => $resource,
940 | ], $time['params']));
941 |
942 | return (int) trim($result);
943 | }
944 |
945 | /**
946 | * Get logs by user and events.
947 | *
948 | * @throws Exception
949 | */
950 | public function getByUserAndEvents(
951 | string $userId,
952 | array $events,
953 | ?\DateTime $after = null,
954 | ?\DateTime $before = null,
955 | int $limit = 25,
956 | int $offset = 0,
957 | bool $ascending = false,
958 | ): array {
959 | $time = $this->buildTimeClause($after, $before);
960 | $order = $ascending ? 'ASC' : 'DESC';
961 | $eventList = $this->buildEventsList($events, 0);
962 | $tableName = $this->getTableName();
963 | $tenantFilter = $this->getTenantFilter();
964 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
965 |
966 | $sql = "
967 | SELECT " . $this->getSelectColumns() . "
968 | FROM {$escapedTable}
969 | WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']}
970 | ORDER BY time {$order}
971 | LIMIT {limit:UInt64} OFFSET {offset:UInt64}
972 | FORMAT TabSeparated
973 | ";
974 |
975 | $result = $this->query($sql, array_merge([
976 | 'userId' => $userId,
977 | 'limit' => $limit,
978 | 'offset' => $offset,
979 | ], $eventList['params'], $time['params']));
980 |
981 | return $this->parseResults($result);
982 | }
983 |
984 | /**
985 | * Count logs by user and events.
986 | *
987 | * @throws Exception
988 | */
989 | public function countByUserAndEvents(
990 | string $userId,
991 | array $events,
992 | ?\DateTime $after = null,
993 | ?\DateTime $before = null,
994 | ): int {
995 | $time = $this->buildTimeClause($after, $before);
996 | $eventList = $this->buildEventsList($events, 0);
997 | $tableName = $this->getTableName();
998 | $tenantFilter = $this->getTenantFilter();
999 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
1000 |
1001 | $sql = "
1002 | SELECT count()
1003 | FROM {$escapedTable}
1004 | WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']}
1005 | FORMAT TabSeparated
1006 | ";
1007 |
1008 | $result = $this->query($sql, array_merge([
1009 | 'userId' => $userId,
1010 | ], $eventList['params'], $time['params']));
1011 |
1012 | return (int) trim($result);
1013 | }
1014 |
1015 | /**
1016 | * Get logs by resource and events.
1017 | *
1018 | * @throws Exception
1019 | */
1020 | public function getByResourceAndEvents(
1021 | string $resource,
1022 | array $events,
1023 | ?\DateTime $after = null,
1024 | ?\DateTime $before = null,
1025 | int $limit = 25,
1026 | int $offset = 0,
1027 | bool $ascending = false,
1028 | ): array {
1029 | $time = $this->buildTimeClause($after, $before);
1030 | $order = $ascending ? 'ASC' : 'DESC';
1031 | $eventList = $this->buildEventsList($events, 0);
1032 | $tableName = $this->getTableName();
1033 | $tenantFilter = $this->getTenantFilter();
1034 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
1035 |
1036 | $sql = "
1037 | SELECT " . $this->getSelectColumns() . "
1038 | FROM {$escapedTable}
1039 | WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']}
1040 | ORDER BY time {$order}
1041 | LIMIT {limit:UInt64} OFFSET {offset:UInt64}
1042 | FORMAT TabSeparated
1043 | ";
1044 |
1045 | $result = $this->query($sql, array_merge([
1046 | 'resource' => $resource,
1047 | 'limit' => $limit,
1048 | 'offset' => $offset,
1049 | ], $eventList['params'], $time['params']));
1050 |
1051 | return $this->parseResults($result);
1052 | }
1053 |
1054 | /**
1055 | * Count logs by resource and events.
1056 | *
1057 | * @throws Exception
1058 | */
1059 | public function countByResourceAndEvents(
1060 | string $resource,
1061 | array $events,
1062 | ?\DateTime $after = null,
1063 | ?\DateTime $before = null,
1064 | ): int {
1065 | $time = $this->buildTimeClause($after, $before);
1066 | $eventList = $this->buildEventsList($events, 0);
1067 | $tableName = $this->getTableName();
1068 | $tenantFilter = $this->getTenantFilter();
1069 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
1070 |
1071 | $sql = "
1072 | SELECT count()
1073 | FROM {$escapedTable}
1074 | WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']}
1075 | FORMAT TabSeparated
1076 | ";
1077 |
1078 | $result = $this->query($sql, array_merge([
1079 | 'resource' => $resource,
1080 | ], $eventList['params'], $time['params']));
1081 |
1082 | return (int) trim($result);
1083 | }
1084 |
1085 | /**
1086 | * Delete logs older than the specified datetime.
1087 | *
1088 | * ClickHouse uses ALTER TABLE DELETE with synchronous mutations.
1089 | *
1090 | * @throws Exception
1091 | */
1092 | public function cleanup(\DateTime $datetime): bool
1093 | {
1094 | $tableName = $this->getTableName();
1095 | $tenantFilter = $this->getTenantFilter();
1096 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName);
1097 |
1098 | // Convert DateTime to string format expected by ClickHouse
1099 | $datetimeString = $datetime->format('Y-m-d H:i:s.v');
1100 |
1101 | // Use DELETE statement for synchronous deletion (ClickHouse 23.3+)
1102 | // Falls back to ALTER TABLE DELETE with mutations_sync for older versions
1103 | $sql = "
1104 | DELETE FROM {$escapedTable}
1105 | WHERE time < {datetime:String}{$tenantFilter}
1106 | ";
1107 |
1108 | $this->query($sql, ['datetime' => $datetimeString]);
1109 |
1110 | return true;
1111 | }
1112 | }
1113 |
--------------------------------------------------------------------------------