├── .github
├── release-drafter.yml
└── workflows
│ ├── build-changelog.yml
│ ├── build-release.yml
│ └── test-unit.yml
├── .gitignore
├── .php-cs-fixer.dist.php
├── LICENSE
├── README.md
├── bootstrap-types.php
├── codecov.yml
├── composer.json
├── docs
├── .readthedocs.yaml
├── advanced.md
├── aggregates.md
├── baseline.txt
├── conditions.md
├── conf.py
├── design.md
├── drawings
│ └── integration.graffle
├── expressions.md
├── fields.md
├── hooks.md
├── images
│ ├── 3layers.png
│ ├── action.gif
│ ├── agiletoolkit.png
│ ├── bd-vs-pd.png
│ ├── deep-traversal.gif
│ ├── domain-model-reports.gif
│ ├── expression.gif
│ ├── import-field.gif
│ ├── integration
│ │ ├── integration-atk.png
│ │ └── integration-orm.png
│ ├── mapping.png
│ ├── model-join.gif
│ ├── presentation.png
│ └── reference-magic.gif
├── index.md
├── joins.md
├── model.md
├── overview.md
├── persistence.md
├── persistence
│ ├── csv.md
│ └── sql
│ │ ├── advanced.md
│ │ ├── connection.md
│ │ ├── expressions.md
│ │ ├── extensions.md
│ │ ├── overview.md
│ │ ├── queries.md
│ │ ├── quickstart.md
│ │ ├── results.md
│ │ └── transactions.md
├── quickstart.md
├── references.md
├── requirements.txt
├── results.md
├── sql.md
├── static.md
├── typecasting.md
└── types.md
├── phpstan.neon.dist
├── phpunit.xml.dist
├── src
├── Exception.php
├── Field.php
├── Field
│ ├── CallbackField.php
│ ├── EmailField.php
│ ├── PasswordField.php
│ └── SqlExpressionField.php
├── Model.php
├── Model
│ ├── AggregateModel.php
│ ├── EntityFieldPair.php
│ ├── FieldPropertiesTrait.php
│ ├── Join.php
│ ├── JoinLinkTrait.php
│ ├── JoinsTrait.php
│ ├── ReferencesTrait.php
│ ├── Scope.php
│ ├── Scope
│ │ ├── AbstractScope.php
│ │ ├── Condition.php
│ │ └── RootScope.php
│ ├── UserAction.php
│ └── UserActionsTrait.php
├── Persistence.php
├── Persistence
│ ├── Array_.php
│ ├── Array_
│ │ ├── Action.php
│ │ ├── Action
│ │ │ └── RenameColumnIterator.php
│ │ ├── Db
│ │ │ ├── Row.php
│ │ │ └── Table.php
│ │ └── Join.php
│ ├── Csv.php
│ ├── GenericPlatform.php
│ ├── Sql.php
│ ├── Sql
│ │ ├── BinaryStringCompatibilityTypecastTrait.php
│ │ ├── Connection.php
│ │ ├── DbalDriverMiddleware.php
│ │ ├── Exception.php
│ │ ├── ExecuteException.php
│ │ ├── ExplicitCastCompatibilityTypecastTrait.php
│ │ ├── Expression.php
│ │ ├── Expressionable.php
│ │ ├── Join.php
│ │ ├── MaterializedField.php
│ │ ├── Mssql
│ │ │ ├── Connection.php
│ │ │ ├── Expression.php
│ │ │ ├── ExpressionTrait.php
│ │ │ ├── PlatformTrait.php
│ │ │ └── Query.php
│ │ ├── Mysql
│ │ │ ├── Connection.php
│ │ │ ├── Expression.php
│ │ │ ├── ExpressionTrait.php
│ │ │ └── Query.php
│ │ ├── Oracle
│ │ │ ├── Connection.php
│ │ │ ├── Expression.php
│ │ │ ├── ExpressionTrait.php
│ │ │ ├── InitializeSessionMiddleware.php
│ │ │ ├── PlatformTrait.php
│ │ │ ├── Query.php
│ │ │ └── SchemaManagerTrait.php
│ │ ├── PlatformFixColumnCommentTypeHintTrait.php
│ │ ├── Postgresql
│ │ │ ├── Connection.php
│ │ │ ├── Expression.php
│ │ │ ├── ExpressionTrait.php
│ │ │ ├── InitializeSessionMiddleware.php
│ │ │ ├── PlatformTrait.php
│ │ │ └── Query.php
│ │ ├── Query.php
│ │ ├── RawExpression.php
│ │ └── Sqlite
│ │ │ ├── Connection.php
│ │ │ ├── CreateConcatFunctionMiddleware.php
│ │ │ ├── CreateRegexpLikeFunctionMiddleware.php
│ │ │ ├── CreateRegexpReplaceFunctionMiddleware.php
│ │ │ ├── Expression.php
│ │ │ ├── ExpressionTrait.php
│ │ │ ├── PlatformTrait.php
│ │ │ ├── PreserveAutoincrementOnRollbackConnectionMiddleware.php
│ │ │ ├── PreserveAutoincrementOnRollbackMiddleware.php
│ │ │ ├── Query.php
│ │ │ └── SchemaManagerTrait.php
│ └── Static_.php
├── Reference.php
├── Reference
│ ├── ContainsBase.php
│ ├── ContainsMany.php
│ ├── ContainsOne.php
│ ├── HasMany.php
│ ├── HasOne.php
│ ├── HasOneSql.php
│ ├── WeakAnalysingBoxedArray.php
│ └── WeakAnalysingMap.php
├── Schema
│ ├── Migrator.php
│ ├── TestCase.php
│ └── TestSqlPersistence.php
├── Type
│ ├── LocalObjectHandle.php
│ ├── LocalObjectType.php
│ ├── MoneyType.php
│ └── Types.php
├── Util
│ ├── DeepCopy.php
│ └── DeepCopyException.php
└── ValidationException.php
└── tests
├── BusinessModelTest.php
├── ConditionSqlTest.php
├── ContainsMany
├── Discount.php
├── Invoice.php
├── Line.php
└── VatRate.php
├── ContainsManyTest.php
├── ContainsOne
├── Address.php
├── Country.php
├── DoorCode.php
└── Invoice.php
├── ContainsOneTest.php
├── ExpressionSqlTest.php
├── Field
├── EmailFieldTest.php
└── PasswordFieldTest.php
├── FieldTest.php
├── FolderTest.php
├── JoinArrayTest.php
├── JoinSqlTest.php
├── LimitOrderTest.php
├── LookupSqlTest.php
├── Model
├── Client.php
├── Female.php
├── Invoice.php
├── Male.php
├── Person.php
├── Smbo
│ ├── Account.php
│ ├── Document.php
│ ├── Payment.php
│ └── Transfer.php
└── User.php
├── ModelAggregateTest.php
├── ModelCheckedUpdateTest.php
├── ModelIteratorTest.php
├── ModelNestedArrayTest.php
├── ModelNestedSqlTest.php
├── ModelWithCteTest.php
├── ModelWithoutIdTest.php
├── Persistence
├── ArrayTest.php
├── CsvTest.php
├── GenericPlatformTest.php
├── Sql
│ ├── ConnectionTest.php
│ ├── ExpressionTest.php
│ ├── QueryTest.php
│ └── WithDb
│ │ ├── SelectTest.php
│ │ └── TransactionTest.php
├── SqlTest.php
└── StaticTest.php
├── RandomTest.php
├── ReadOnlyModeTest.php
├── Reference
└── WeakAnalysingMapTest.php
├── ReferenceSqlTest.php
├── ReferenceTest.php
├── Schema
├── MigratorFkTest.php
├── MigratorTest.php
└── TestCaseTest.php
├── ScopeTest.php
├── SerializeTest.php
├── SmboTransferTest.php
├── SubTypesTest.php
├── TransactionTest.php
├── Type
└── LocalObjectTypeTest.php
├── TypecastingTest.php
├── UserActionTest.php
├── Util
└── DeepCopyTest.php
└── ValidationTest.php
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | categories:
2 | - title: "Major features"
3 | labels:
4 | - "MAJOR"
5 | - title: "Breaking changes"
6 | labels:
7 | - "BC-break"
8 | - title: "Other changes"
9 | template: $CHANGES
10 |
--------------------------------------------------------------------------------
/.github/workflows/build-changelog.yml:
--------------------------------------------------------------------------------
1 | name: Build changelog
2 |
3 | on:
4 | push:
5 | branches:
6 | - develop
7 |
8 | jobs:
9 | update:
10 | name: Update
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Run
14 | uses: release-drafter/release-drafter@v6
15 | env:
16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
17 |
--------------------------------------------------------------------------------
/.github/workflows/build-release.yml:
--------------------------------------------------------------------------------
1 | name: Build release
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'release/*'
7 |
8 | jobs:
9 | autocommit:
10 | name: Build Release
11 | runs-on: ubuntu-latest
12 | container:
13 | image: ghcr.io/mvorisek/image-php:latest
14 | steps:
15 | - uses: actions/checkout@v4
16 | with:
17 | ref: ${{ github.ref }}
18 |
19 | - name: Install PHP dependencies
20 | run: composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader
21 |
22 | - name: Update composer.json
23 | run: >-
24 | composer config --unset version && php -r '
25 | $f = __DIR__ . "/composer.json";
26 | $data = json_decode(file_get_contents($f), true);
27 | foreach ($data as $k => $v) {
28 | if (preg_match("~^(.+)-release$~", $k, $matches)) {
29 | $data[$matches[1]] = $data[$k]; unset($data[$k]);
30 | }
31 | }
32 | $str = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
33 | echo $str;
34 | file_put_contents($f, $str);
35 | '
36 |
37 | - name: Commit
38 | run: |
39 | git config --global user.name "$(git show -s --format='%an')"
40 | git config --global user.email "$(git show -s --format='%ae')"
41 | git add . -N && (git diff --exit-code || git commit -a -m "Branch for stable release")
42 |
43 | - name: Push
44 | uses: ad-m/github-push-action@master
45 | with:
46 | branch: ${{ github.ref }}
47 | github_token: ${{ secrets.GITHUB_TOKEN }}
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /docs/build
2 | /coverage
3 | /vendor
4 | /composer.lock
5 | .idea
6 | nbproject
7 | .vscode
8 | .DS_Store
9 |
10 | local
11 | *.local
12 | *.local.*
13 | cache
14 | *.cache
15 | *.cache.*
16 |
17 | /phpunit.xml
18 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | in([__DIR__])
10 | ->exclude(['vendor']);
11 |
12 | return (new Config())
13 | ->setRiskyAllowed(true)
14 | ->setRules([
15 | '@PhpCsFixer' => true,
16 | '@PhpCsFixer:risky' => true,
17 | '@PHP74Migration' => true,
18 | '@PHP74Migration:risky' => true,
19 |
20 | // required by PSR-12
21 | 'concat_space' => [
22 | 'spacing' => 'one',
23 | ],
24 |
25 | // disable some too strict rules
26 | 'phpdoc_types_order' => [
27 | 'null_adjustment' => 'always_last',
28 | 'sort_algorithm' => 'none',
29 | ],
30 | 'single_line_throw' => false,
31 | 'yoda_style' => [
32 | 'equal' => false,
33 | 'identical' => false,
34 | ],
35 | 'native_constant_invocation' => true,
36 | 'native_function_invocation' => false,
37 | 'void_return' => false,
38 | 'blank_line_before_statement' => [
39 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'exit'],
40 | ],
41 | 'final_internal_class' => false,
42 | 'combine_consecutive_issets' => false,
43 | 'combine_consecutive_unsets' => false,
44 | 'multiline_whitespace_before_semicolons' => false,
45 | 'no_superfluous_elseif' => false,
46 | 'ordered_class_elements' => false,
47 | 'php_unit_internal_class' => false,
48 | 'php_unit_test_class_requires_covers' => false,
49 | 'phpdoc_add_missing_param_annotation' => false,
50 | 'return_assignment' => false,
51 | 'comment_to_phpdoc' => false,
52 | 'general_phpdoc_annotation_remove' => [
53 | 'annotations' => ['author', 'copyright', 'throws'],
54 | ],
55 |
56 | // fn => without curly brackets is less readable,
57 | // also prevent bounding of unwanted variables for GC
58 | 'use_arrow_functions' => false,
59 | ])
60 | ->setFinder($finder)
61 | ->setCacheFile(sys_get_temp_dir() . '/php-cs-fixer.' . md5(__DIR__) . '.cache');
62 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Agile Toolkit Limited (UK)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, 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,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/bootstrap-types.php:
--------------------------------------------------------------------------------
1 | =7.4 <8.4",
37 | "atk4/core": "dev-develop",
38 | "doctrine/dbal": "~3.5.1 || ~3.6.0",
39 | "mvorisek/atk4-hintable": "~1.9.0"
40 | },
41 | "require-release": {
42 | "php": ">=7.4 <8.4",
43 | "atk4/core": "~6.1.0",
44 | "doctrine/dbal": "~3.5.1 || ~3.6.0",
45 | "mvorisek/atk4-hintable": "~1.9.0"
46 | },
47 | "require-dev": {
48 | "doctrine/sql-formatter": "dev-1-5-php74 as 1.5.99",
49 | "ergebnis/composer-normalize": "^2.13",
50 | "ergebnis/phpunit-slow-test-detector": "^2.9",
51 | "friendsofphp/php-cs-fixer": "^3.0",
52 | "phpstan/extension-installer": "^1.1",
53 | "phpstan/phpstan": "^2.0",
54 | "phpstan/phpstan-deprecation-rules": "^2.0",
55 | "phpstan/phpstan-strict-rules": "^2.0",
56 | "phpunit/phpunit": "^9.5.25 || ^10.0 || ^11.0"
57 | },
58 | "conflict": {
59 | "doctrine/sql-formatter": "<1.5 || >=2.0"
60 | },
61 | "suggest": {
62 | "doctrine/sql-formatter": "*"
63 | },
64 | "repositories": [
65 | {
66 | "type": "git",
67 | "url": "https://github.com/atk4/doctrine-sql-formatter.git",
68 | "name": "doctrine/sql-formatter"
69 | }
70 | ],
71 | "minimum-stability": "dev",
72 | "prefer-stable": true,
73 | "autoload": {
74 | "psr-4": {
75 | "Atk4\\Data\\": "src/"
76 | },
77 | "files": [
78 | "bootstrap-types.php"
79 | ]
80 | },
81 | "autoload-dev": {
82 | "psr-4": {
83 | "Atk4\\Data\\Tests\\": "tests/"
84 | }
85 | },
86 | "config": {
87 | "allow-plugins": {
88 | "ergebnis/composer-normalize": true,
89 | "phpstan/extension-installer": true
90 | },
91 | "sort-packages": true
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/docs/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: ubuntu-lts-latest
5 | tools:
6 | # https://github.com/readthedocs/readthedocs.org/issues/9719
7 | python: '3'
8 |
9 | python:
10 | install:
11 | # https://github.com/readthedocs/readthedocs.org/issues/10806
12 | - requirements: docs/requirements.txt
13 |
14 | sphinx:
15 | # https://github.com/readthedocs/readthedocs.org/issues/10806
16 | configuration: docs/conf.py
17 |
18 | formats:
19 | - pdf
20 | - epub
21 | - htmlzip
22 |
--------------------------------------------------------------------------------
/docs/aggregates.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data
2 | :::
3 |
4 | (Aggregates)=
5 |
6 | # Model Aggregates
7 |
8 | :::{php:class} Model\AggregateModel
9 | :::
10 |
11 | In order to create model aggregates the AggregateModel model needs to be used:
12 |
13 | ## Grouping
14 |
15 | AggregateModel model can be used for grouping:
16 |
17 | ```
18 | $aggregate = new AggregateModel($orders)->setGroupBy(['country_id']);
19 | ```
20 |
21 | `$aggregate` above is a new object that is most appropriate for the model's persistence and which can be manipulated
22 | in various ways to fine-tune aggregation. Below is one sample use:
23 |
24 | ```
25 | $aggregate = new AggregateModel($orders);
26 | $aggregate->addField('country');
27 | $aggregate->setGroupBy(['country_id'], [
28 | 'count' => ['expr' => 'count(*)', 'type' => 'bigint'],
29 | 'total_amount' => ['expr' => 'sum([amount])', 'type' => 'atk4_money'],
30 | ],
31 | );
32 |
33 | // $aggregate will have following rows:
34 | // ['country' => 'UK', 'count' => 20, 'total_amount' => 123.2];
35 | // ...
36 | ```
37 |
38 | Below is how opening balance can be built:
39 |
40 | ```
41 | $ledger = new GeneralLedger($db);
42 | $ledger->addCondition('date', '<', $from);
43 |
44 | // we actually need grouping by nominal
45 | $ledger->setGroupBy(['nominal_id'], [
46 | 'opening_balance' => ['expr' => 'sum([amount])', 'type' => 'atk4_money'],
47 | ]);
48 | ```
49 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import os
3 | import sphinx_rtd_theme
4 |
5 | from sphinx.highlighting import lexers
6 | from pygments.lexers.web import PhpLexer
7 | lexers['php'] = PhpLexer(startinline=True, linenos=1)
8 | lexers['php-annotations'] = PhpLexer(startinline=True, linenos=1)
9 | primary_domain = 'php'
10 |
11 | extensions = [
12 | 'sphinx.ext.autodoc',
13 | 'sphinx.ext.intersphinx',
14 | 'sphinx.ext.todo',
15 | 'sphinx.ext.coverage',
16 | 'sphinxcontrib.phpdomain',
17 | 'myst_parser',
18 | ]
19 |
20 | myst_enable_extensions = ['colon_fence', 'linkify']
21 |
22 | source_suffix = '.md'
23 |
24 | master_doc = 'index'
25 |
26 | # General information about the project.
27 | project = u'Agile Data'
28 | copyright = u'2016-2025, Agile Toolkit'
29 |
30 | exclude_patterns = ['_build']
31 |
32 | # If true, '()' will be appended to :func: etc. cross-reference text.
33 | #add_function_parentheses = True
34 |
35 | # If true, the current module name will be prepended to all description
36 | # unit titles (such as .. function::).
37 | #add_module_names = True
38 |
39 | # If true, sectionauthor and moduleauthor directives will be shown in the
40 | # output. They are ignored by default.
41 | #show_authors = False
42 |
43 | # The name of the Pygments (syntax highlighting) style to use.
44 | pygments_style = 'sphinx'
45 |
46 | # A list of ignored prefixes for module index sorting.
47 | #modindex_common_prefix = []
48 |
49 | highlight_language = 'php'
50 |
51 | # If true, keep warnings as "system message" paragraphs in the built documents.
52 | #keep_warnings = False
53 |
54 | # -- Options for HTML output ----------------------------------------------
55 |
56 | # The theme to use for HTML and HTML Help pages. See the documentation for
57 | # a list of builtin themes.
58 | html_theme = 'sphinx_rtd_theme'
59 |
60 | # Add any extra paths that contain custom files (such as robots.txt or
61 | # .htaccess) here, relative to this directory. These files are copied
62 | # directly to the root of the documentation.
63 | #html_extra_path = []
64 |
65 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
66 | # using the given strftime format.
67 | #html_last_updated_fmt = '%b %d, %Y'
68 |
69 | # If true, SmartyPants will be used to convert quotes and dashes to
70 | # typographically correct entities.
71 | #html_use_smartypants = True
72 |
73 | # Custom sidebar templates, maps document names to template names.
74 | #html_sidebars = {}
75 |
76 | # Additional templates that should be rendered to pages, maps page names to
77 | # template names.
78 | #html_additional_pages = {}
79 |
80 | # If false, no module index is generated.
81 | #html_domain_indices = True
82 |
83 | # If false, no index is generated.
84 | #html_use_index = True
85 |
86 | # If true, the index is split into individual pages for each letter.
87 | #html_split_index = False
88 |
89 | # If true, links to the reST sources are added to the pages.
90 | #html_show_sourcelink = True
91 |
92 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
93 | #html_show_sphinx = True
94 |
95 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
96 | #html_show_copyright = True
97 |
98 | # If true, an OpenSearch description file will be output, and all pages will
99 | # contain a tag referring to it. The value of this option must be the
100 | # base URL from which the finished HTML is served.
101 | #html_use_opensearch = ''
102 |
103 | # This is the file name suffix for HTML files (e.g. ".xhtml").
104 | #html_file_suffix = None
105 |
106 | # Output file base name for HTML help builder.
107 | htmlhelp_basename = 'AgileDataDoc'
108 |
109 | # -- Options for manual page output ---------------------------------------
110 |
111 | # One entry per manual page. List of tuples
112 | # (source start file, name, description, authors, manual section).
113 | man_pages = [
114 | ('index', 'agile-data', u'Agile Data Documentation',
115 | [u'Agile Toolkit'], 1),
116 | ]
117 |
118 | # If true, show URL addresses after external links.
119 | #man_show_urls = False
120 |
121 | # -- Options for Texinfo output -------------------------------------------
122 |
123 | # Grouping the document tree into Texinfo files. List of tuples
124 | # (source start file, target name, title, author,
125 | # dir menu entry, description, category)
126 | texinfo_documents = [
127 | ('index', 'AgileData', u'Agile Data Documentation',
128 | u'Agile Toolkit', 'Agile Data', 'One line description of project.',
129 | 'Miscellaneous'),
130 | ]
131 |
132 | # Documents to append as an appendix to all manuals.
133 | #texinfo_appendices = []
134 |
135 | # If false, no module index is generated.
136 | #texinfo_domain_indices = True
137 |
138 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
139 | #texinfo_show_urls = 'footnote'
140 |
141 | # If true, do not generate a @detailmenu in the "Top" node's menu.
142 | #texinfo_no_detailmenu = False
143 |
144 | # Example configuration for intersphinx: refer to the Python standard library.
145 | intersphinx_mapping = {'https://docs.python.org/': None}
146 |
147 | from sphinx.highlighting import lexers
148 | from pygments.lexers.web import PhpLexer
149 | lexers['php'] = PhpLexer(startinline=True)
150 | lexers['php-annotations'] = PhpLexer(startinline=True)
151 | primary_domain = "php" # It seems to help sphinx in some kind (don't know why)
152 |
--------------------------------------------------------------------------------
/docs/drawings/integration.graffle:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/drawings/integration.graffle
--------------------------------------------------------------------------------
/docs/images/3layers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/3layers.png
--------------------------------------------------------------------------------
/docs/images/action.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/action.gif
--------------------------------------------------------------------------------
/docs/images/agiletoolkit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/agiletoolkit.png
--------------------------------------------------------------------------------
/docs/images/bd-vs-pd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/bd-vs-pd.png
--------------------------------------------------------------------------------
/docs/images/deep-traversal.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/deep-traversal.gif
--------------------------------------------------------------------------------
/docs/images/domain-model-reports.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/domain-model-reports.gif
--------------------------------------------------------------------------------
/docs/images/expression.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/expression.gif
--------------------------------------------------------------------------------
/docs/images/import-field.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/import-field.gif
--------------------------------------------------------------------------------
/docs/images/integration/integration-atk.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/integration/integration-atk.png
--------------------------------------------------------------------------------
/docs/images/integration/integration-orm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/integration/integration-orm.png
--------------------------------------------------------------------------------
/docs/images/mapping.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/mapping.png
--------------------------------------------------------------------------------
/docs/images/model-join.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/model-join.gif
--------------------------------------------------------------------------------
/docs/images/presentation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/presentation.png
--------------------------------------------------------------------------------
/docs/images/reference-magic.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atk4/data/421667bc025e0defd3e17372619def582201d5d8/docs/images/reference-magic.gif
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data
2 | :::
3 |
4 | # Agile Data Documentation
5 |
6 | Contents:
7 |
8 | :::{toctree}
9 | :maxdepth: 3
10 |
11 | overview
12 | quickstart
13 | design
14 | model
15 | typecasting
16 | persistence
17 | results
18 | fields
19 | conditions
20 | sql
21 | static
22 | references
23 | expressions
24 | joins
25 | aggregates
26 | hooks
27 | deriving
28 | advanced
29 | extensions
30 | persistence/csv
31 | :::
32 |
33 | # Agile DSQL Documentation
34 |
35 | Contents:
36 |
37 | :::{toctree}
38 | :maxdepth: 3
39 | :caption: DSQL
40 |
41 | persistence/sql/overview
42 | persistence/sql/quickstart
43 | persistence/sql/connection
44 | persistence/sql/expressions
45 | persistence/sql/queries
46 | persistence/sql/results
47 | persistence/sql/transactions
48 | persistence/sql/advanced
49 | persistence/sql/extensions
50 | :::
51 |
52 | # Indices and tables
53 |
54 | - {ref}`genindex`
55 | - {ref}`search`
56 |
--------------------------------------------------------------------------------
/docs/persistence/csv.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data
2 | :::
3 |
4 | (Persistence_Csv)=
5 |
6 | # Loading and Saving CSV Files
7 |
8 | :::{php:class} Persistence\Csv
9 | :::
10 |
11 | Agile Data can operate with CSV files for data loading, or saving. The capabilities
12 | of `Persistence\Csv` are limited to the following actions:
13 |
14 | - open any CSV file, use column mapping
15 | - identify which column is corresponding for respective field
16 | - support custom mapper, e.g. if date is stored in a weird way
17 | - support for CSV files that have extra lines on top/bottom/etc
18 | - listing/iterating
19 | - adding a new record
20 |
21 | ## Setting Up
22 |
23 | When creating new persistence you must provide a valid URL for
24 | the file that might be stored either on a local system or
25 | use a remote file scheme (ftp://...). The file will not be
26 | actually opened unless you perform load/save operation:
27 |
28 | ```
29 | $p = new Persistence\Csv('myfile.csv');
30 |
31 | $u = new Model_User($p);
32 | $u = $u->tryLoadAny(); // actually opens file and finds first record
33 | ```
34 |
35 | ## Exporting and Importing data from CSV
36 |
37 | You can take a model that is loaded from other persistence and save
38 | it into CSV like this. The next example demonstrates a basic functionality
39 | of SQL database export to CSV file:
40 |
41 | ```
42 | $db = new Persistence\Sql($connection);
43 | $csv = new Persistence\Csv('dump.csv');
44 |
45 | $m = new Model_User($db);
46 |
47 | foreach (new Model_User($db) as $m) {
48 | $m->withPersistence($csv)->save();
49 | }
50 | ```
51 |
52 | Theoretically you can do few things to tweak this process. You can specify
53 | which fields you would like to see in the CSV:
54 |
55 | ```
56 | foreach (new Model_User($db) as $m) {
57 | $m->withPersistence($csv)
58 | ->setOnlyFields(['id', 'name', 'password'])
59 | ->save();
60 | }
61 | ```
62 |
63 | Additionally if you want to use a different column titles, you can:
64 |
65 | ```
66 | foreach (new Model_User($db) as $m) {
67 | $mCsv = $m->withPersistence($csv);
68 | $mCsv->setOnlyFields(['id', 'name', 'password'])
69 | $mCsv->getField('name')->actual = 'First Name';
70 | $mCsv->save();
71 | }
72 | ```
73 |
74 | Like with any other persistence you can use typecasting if you want data to be
75 | stored in any particular format.
76 |
77 | The examples above also create object on each iteration, that may appear as
78 | a performance inefficiency. This can be solved by re-using Csv model through
79 | iterations:
80 |
81 | ```
82 | $m = new Model_User($db);
83 | $mCsv = $m->withPersistence($csv);
84 | $mCsv->setOnlyFields(['id', 'name', 'password'])
85 | $mCsv->getField('name')->actual = 'First Name';
86 |
87 | foreach ($m as $mCsv) {
88 | $mCsv->save();
89 | }
90 | ```
91 |
92 | This code can be further simplified if you use import() method:
93 |
94 | ```
95 | $m = new Model_User($db);
96 | $mCsv = $m->withPersistence($csv);
97 | $mCsv->setOnlyFields(['id', 'name', 'password'])
98 | $mCsv->getField('name')->actual = 'First Name';
99 | $mCsv->import($m);
100 | ```
101 |
102 | Naturally you can also move data in the other direction:
103 |
104 | ```
105 | $m = new Model_User($db);
106 | $mCsv = $m->withPersistence($csv);
107 | $mCsv->setOnlyFields(['id', 'name', 'password'])
108 | $mCsv->getField('name')->actual = 'First Name';
109 |
110 | $m->import($mCsv);
111 | ```
112 |
113 | Only the last line changes and the data will now flow in the other direction.
114 |
--------------------------------------------------------------------------------
/docs/persistence/sql/connection.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data\Persistence\Sql
2 | :::
3 |
4 | (connect)=
5 |
6 | # Connection
7 |
8 | DSQL supports various database vendors natively but also supports 3rd party
9 | extensions.
10 | For current status on database support see: {ref}`databases`.
11 |
12 | :::{php:class} Connection
13 | :::
14 |
15 | Connection class is handy to have if you plan on building and executing
16 | queries in your application. It's more appropriate to store
17 | connection in a global variable or global class:
18 |
19 | ```
20 | $app->db = Atk4\Data\Persistence\Sql\Connection::connect($dsn, $user, $pass, $defaults);
21 | ```
22 |
23 | :::{php:method} static connect($dsn, $user = null, $password = null, $defaults = [])
24 | Determine which Connection class should be used for specified $dsn,
25 | establish connection to DB by creating new object of this connection class and return.
26 |
27 | ```{eval-rst}
28 | :param string $dsn: DSN, see https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html
29 | :param string $user: username
30 | :param string $password: password
31 | :param array $defaults: Other default properties for connection class.
32 | :returns: new Connection
33 | ```
34 | :::
35 |
36 | This should allow you to access this class from anywhere and generate either
37 | new Query or Expression class:
38 |
39 | ```
40 | $query = $app->db->dsql();
41 |
42 | // or
43 |
44 | $expr = $app->db->expr('show tables');
45 | ```
46 |
47 | :::{php:method} expr($template, $arguments)
48 | Creates new Expression class and sets {php:attr}`Expression::$connection`.
49 |
50 | ```{eval-rst}
51 | :param array $arguments: Other default properties for connection class.
52 | :returns: new Expression
53 | ```
54 | :::
55 |
56 | :::{php:method} dsql($defaults)
57 | Creates new Query class and sets {php:attr}`Query::$connection`.
58 |
59 | ```{eval-rst}
60 | :param array $defaults: Other default properties for connection class.
61 | :returns: new Query
62 | ```
63 | :::
64 |
65 | Here is how you can use all of this together:
66 |
67 | ```
68 | $dsn = 'mysql:host=localhost;port=3307;dbname=testdb';
69 |
70 | $connection = Atk4\Data\Persistence\Sql\Connection::connect($dsn, 'root', 'root');
71 |
72 | echo 'Time now is: ' . $connection->expr('select now()');
73 | ```
74 |
75 | {php:meth}`connect` will determine appropriate class that can be used for this
76 | DSN string. This can be a PDO class or it may try to use a 3rd party connection
77 | class.
78 |
79 | Connection class is also responsible for executing queries. This is only used
80 | if you connect to vendor that does not use PDO.
81 |
82 | :::{php:method} execute(Expression $expr): \Doctrine\DBAL\Result
83 | Creates new Expression class and sets {php:attr}`Expression::$connection`.
84 |
85 | ```{eval-rst}
86 | :param Expression $expr: Expression (or query) to execute
87 | :returns: `Doctrine\\DBAL\\Result`
88 | ```
89 | :::
90 |
91 | :::{php:method} registerConnectionClass($connectionClass, $connectionType)
92 | Adds connection class to the registry for resolving in Connection::resolveConnectionClass method.
93 |
94 | ```{eval-rst}
95 | :param string $connectionClass: The connection class to be used for the diver type
96 | :param string $connectionType: Alias of the connection
97 | ```
98 | :::
99 |
100 | Developers can register custom classes to handle driver types using the `Connection::registerConnectionClass` method:
101 |
102 | ```
103 | Connection::registerConnectionClass(Custom\MySQL\Connection::class, 'pdo_mysql');
104 | ```
105 |
106 | :::{php:method} connectDbalConnection(array $dsn)
107 | The method should establish connection with DB and return the underlying connection object used by
108 | the `Connection` class. By default PDO is used but the method can be overridden to return custom object to be
109 | used for connection to DB.
110 | :::
111 |
--------------------------------------------------------------------------------
/docs/persistence/sql/extensions.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data\Persistence\Sql
2 | :::
3 |
4 | (databases)=
5 |
6 | # Vendor support and Extensions
7 |
8 | | Vendor | Support | PDO | Dependency |
9 | | ---------- | -------- | ------- | ----------- |
10 | | MySQL | Full | mysql: | native, PDO |
11 | | SQLite | Full | sqlite: | native, PDO |
12 | | Oracle | Untested | oci: | native, PDO |
13 | | PostgreSQL | Untested | pgsql: | native, PDO |
14 | | MSSQL | Untested | mssql: | native, PDO |
15 |
16 | :::{note}
17 | Most PDO vendors should work out of the box
18 | :::
19 |
20 | ## 3rd party vendor support
21 |
22 | | Class | Support | PDO | Dependency |
23 | | ------------------- | ------- | --------- | ---------------------------- |
24 | | Connection_MyVendor | Full | myvendor: | https://github/test/myvendor |
25 |
26 | See {ref}`new_vendor` for more details on how to add support for your driver.
27 |
--------------------------------------------------------------------------------
/docs/persistence/sql/overview.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data\Persistence\Sql
2 | :::
3 |
4 | # Overview
5 |
6 | DSQL is a dynamic SQL query builder. You can write multi-vendor queries in PHP
7 | profiting from better security, clean syntax and most importantly – sub-query
8 | support. With DSQL you stay in control of when queries are executed and what
9 | data is transmitted. DSQL is easily composable – build one query and use it as
10 | a part of other query.
11 |
12 | ## Goals of DSQL
13 |
14 | - simple and concise syntax
15 | - consistently scalable (e.g. 5 levels of sub-queries, 10 with joins and 15
16 | parameters? no problem)
17 | - "One Query" paradigm
18 | - support for PDO vendors as well as NoSQL databases (with query language
19 | similar to SQL)
20 | - small code footprint (over 50% less than competing frameworks)
21 | - free, licensed under MIT
22 | - no dependencies
23 | - follows design paradigms:
24 | - "[PHP the Agile way](https://github.com/atk4/dsql/wiki/PHP-the-Agile-way)"
25 | - "[Functional ORM](https://github.com/atk4/dsql/wiki/Functional-ORM)"
26 | - "[Open to extend](https://github.com/atk4/dsql/wiki/Open-to-Extend)"
27 | - "[Vendor Transparency](https://github.com/atk4/dsql/wiki/Vendor-Transparency)"
28 |
29 | ## DSQL by example
30 |
31 | The simplest way to explain DSQL is by example:
32 |
33 | ```
34 | $query = $connection->dsql();
35 | $query->table('employees')
36 | ->where('birth_date', '1961-05-02')
37 | ->field('count(*)');
38 | echo 'Employees born on May 2, 1961: ' . $query->getOne();
39 | ```
40 |
41 | The above code will execute the following query:
42 |
43 | ```sql
44 | select count(*) from `salary` where `birth_date` = :a
45 | :a = "1961-05-02"
46 | ```
47 |
48 | DSQL can also execute queries with multiple sub-queries, joins, expressions
49 | grouping, ordering, unions as well as queries on result-set.
50 |
51 | - See {ref}`quickstart` if you would like to start learning DSQL.
52 |
53 | ## DSQL is Part of Agile Toolkit
54 |
55 | DSQL is a stand-alone and lightweight library with no dependencies and can be
56 | used in any PHP project, big or small.
57 |
58 | :::{figure} ../../images/agiletoolkit.png
59 | :alt: Agile Toolkit Stack
60 | :::
61 |
62 | DSQL is also a part of [Agile Toolkit](https://atk4.org/) framework and works best with
63 | [Agile Models](https://github.com/atk4/models). Your project may benefit from a higher-level data abstraction
64 | layer, so be sure to look at the rest of the suite.
65 |
--------------------------------------------------------------------------------
/docs/persistence/sql/results.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data\Persistence\Sql
2 | :::
3 |
4 | # Results
5 |
6 | When query is executed by {php:class}`Connection` or
7 | [PDO](https://php.net/manual/en/pdo.query.php), it will return an object that
8 | can stream results back to you. The PDO class execution produces a
9 | `Doctrine\DBAL\Result` object which you can iterate over.
10 |
11 | If you are using a custom connection, you then will also need a custom object
12 | for streaming results.
13 |
14 | The only requirement for such an object is that it has to be a
15 | [Generator](https://php.net/manual/en/language.generators.syntax.php).
16 | In most cases developers will expect your generator to return sequence
17 | of id => hash representing a key/value result set.
18 |
19 | :::{todo}
20 | write more
21 | :::
22 |
--------------------------------------------------------------------------------
/docs/persistence/sql/transactions.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data\Persistence\Sql
2 | :::
3 |
4 | # Transactions
5 |
6 | When you work with the DSQL, you can work with transactions. There are 2
7 | enhancements to the standard functionality of transactions in DSQL:
8 |
9 | 1. You can start nested transactions.
10 | 2. You can use {php:meth}`Connection::atomic()` which has a nicer syntax.
11 |
12 | It is recommended to always use atomic() in your code.
13 |
14 | :::{php:class} Connection
15 | :::
16 |
17 | :::{php:method} atomic($callback)
18 | Execute callback within the SQL transaction. If callback encounters an
19 | exception, whole transaction will be automatically rolled back:
20 |
21 | ```
22 | $c->atomic(function () use ($c) {
23 | $c->dsql('user')->set('balance = balance + 10')->where('id', 10)->mode('update')->executeStatement();
24 | $c->dsql('user')->set('balance = balance - 10')->where('id', 14)->mode('update')->executeStatement();
25 | });
26 | ```
27 |
28 | atomic() can be nested.
29 | The successful completion of a top-most method will commit everything.
30 | Rollback of a top-most method will roll back everything.
31 | :::
32 |
33 | :::{php:method} beginTransaction
34 | Start new transaction. If already started, will do nothing but will increase
35 | transaction depth.
36 | :::
37 |
38 | :::{php:method} commit
39 | Will commit transaction, however if {php:meth}`Connection::beginTransaction`
40 | was executed more than once, will only decrease transaction depth.
41 | :::
42 |
43 | :::{php:method} inTransaction
44 | Returns true if transaction is currently active. There is no need for you to
45 | ever use this method.
46 | :::
47 |
48 | :::{php:method} rollBack
49 | Roll-back the transaction, however if {php:meth}`Connection::beginTransaction`
50 | was executed more than once, will only decrease transaction depth.
51 | :::
52 |
53 | :::{warning}
54 | If you roll-back internal transaction and commit external
55 | transaction, then result might be unpredictable.
56 | Please discuss this https://github.com/atk4/dsql/issues/89
57 | :::
58 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx<5
2 | standard-imghdr
3 | sphinxcontrib-applehelp<=1.0.4
4 | sphinxcontrib-devhelp<=1.0.2
5 | sphinxcontrib-htmlhelp<=2.0.1
6 | sphinxcontrib-jsmath<=1.0.1
7 | sphinxcontrib-qthelp<=1.0.3
8 | sphinxcontrib-serializinghtml<=1.1.5
9 | sphinx-rtd-theme
10 | sphinxcontrib-phpdomain @ git+https://github.com/markstory/sphinxcontrib-phpdomain@6e244a1ac2
11 | myst-parser
12 | linkify-it-py
13 |
--------------------------------------------------------------------------------
/docs/results.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data
2 | :::
3 |
4 | # Fetching results
5 |
6 | :::{php:class} Model
7 | :::
8 |
9 | Model linked to a persistence is your "window" into DataSet and you get several
10 | ways which allow you to fetch the data.
11 |
12 | ## Iterate through model data
13 |
14 | :::{php:method} getIterator()
15 | :::
16 |
17 | Create your persistence object first then iterate it:
18 |
19 | ```
20 | $db = \Atk4\Data\Persistence::connect($dsn);
21 | $m = new Model_Client($db);
22 |
23 | foreach ($m as $id => $entity) {
24 | echo $id . ': ' . $entity->get('name') . "\n";
25 | }
26 | ```
27 |
28 | :::{note}
29 | changing query parameter during iteration will has no effect until you
30 | finish iterating.
31 | :::
32 |
33 | ### Raw Data Fetching
34 |
35 | If you do not care about the hooks and simply wish to get the data, you can fetch
36 | it:
37 |
38 | ```
39 | foreach ($m->getPersistence()->prepareIterator($m) as $row) {
40 | var_dump($row); // array
41 | }
42 | ```
43 |
44 | The $row will also contain value for "id" and it's up to you to find it yourself
45 | if you need it.
46 |
47 | :::{php:method} export()
48 | :::
49 |
50 | Will fetch and output array of hashes which will represent entirety of data-set.
51 | Similarly to other methods, this will have the data mapped into your fields for
52 | you and server-side expressions executed that are embedded in the query.
53 |
54 | By default - `onlyFields` will be presented as well as system fields.
55 |
56 | ### Fetching data through action
57 |
58 | You can invoke and iterate action (particularly SQL) to fetch the data:
59 |
60 | ```
61 | foreach ($m->action('select')->getRowsIterator() as $row) {
62 | var_dump($row); // array
63 | }
64 | ```
65 |
66 | This has the identical behavior to `$m->getPersistence()->prepareIterator($m)`.
67 |
--------------------------------------------------------------------------------
/docs/static.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data
2 | :::
3 |
4 | (Static)=
5 |
6 | # Static Persistence
7 |
8 | :::{php:class} Persistence\Static_
9 | :::
10 |
11 | Static Persistence extends {php:class}`Persistence\Array_` to implement
12 | a user-friendly way of specifying data through an array.
13 |
14 | ## Usage
15 |
16 | This is most useful when working with "sample" code, where you want to see your
17 | results quick:
18 |
19 | ```
20 | $table->setModel(new Model(new Persistence\Static_([
21 | ['VAT_rate' => '12.0%', 'VAT' => '36.00', 'Net' => '300.00'],
22 | ['VAT_rate' => '10.0%', 'VAT' => '52.00', 'Net' => '520.00'],
23 | ])));
24 | ```
25 |
26 | Lets unwrap the example:
27 |
28 | :::{php:method} __construct
29 | :::
30 |
31 | Constructor accepts array as an argument, but the array could be in various forms:
32 |
33 | - can be array of strings ['one', 'two']
34 | - can be array of hashes. First hash will be examined to pick up fields
35 | - can be array of arrays. Will name columns as 'field1', 'field2', 'field3'.
36 |
37 | If you are using any fields without keys (numeric keys) it's important that all
38 | your records have same number of elements.
39 |
40 | Static Persistence will also make attempt to deduce a "title" field and will set
41 | it automatically for the model. If you have a field with key "name" then it will
42 | be used.
43 | Alternative it will check key "title".
44 |
45 | If neither are present you can still manually specify title field for your model.
46 |
47 | Finally, static persistence (unlike {php:class}`Persistence\Array_`) will automatically
48 | populate fields for the model and will even attempt to deduce field types.
49 |
50 | Currently it recognizes integer, date, boolean, float, array and object types.
51 | Other fields will appear as-is.
52 |
53 | ### Saving Records
54 |
55 | Models that you specify against static persistence will not be marked as
56 | "Read Only" ({php:attr}`Model::$readOnly`), and you will be allowed to save
57 | data back. The data will only be stored inside persistence object and will be
58 | discarded at the end of your PHP script.
59 |
--------------------------------------------------------------------------------
/docs/typecasting.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data
2 | :::
3 |
4 | (Typecasting)=
5 |
6 | # Typecasting
7 |
8 | Typecasting is evoked when you are attempting to save or load the record.
9 | Unlike strict types and normalization, typecasting is a persistence-specific
10 | operation. Here is the sequence and sample:
11 |
12 | ```
13 | $m->addField('birthday', ['type' => 'date']);
14 | // Type has a number of pre-defined values. Using 'date'
15 | // instructs AD that we will be using it for staring dates
16 | // through DateTime class
17 |
18 | $m->set('birthday', 'Jan 1 1960');
19 | // If non-compatible value is provided, it will be converted
20 | // into a proper date through Normalization process. After
21 | // this line value of 'birthday' field will be DateTime.
22 |
23 | $m->save();
24 | // At this point typecasting converts the "DateTime" value
25 | // into UTC date-time representation for SQL or "MongoDate"
26 | // type if you're persisting with MongoDB. This does not affect
27 | // value of a model field.
28 | ```
29 |
30 | Typecasting is necessary to save the values inside the database and restore
31 | them back just as they were before.
32 |
33 | The purpose of a flexible typecasting system is to allow you to store your date
34 | in a compatible format or even fine-tune it to match your database settings
35 | (e.g. timezone) without affecting your domain code.
36 |
37 | You must remember that type-casting is a two-way operation. If you are
38 | introducing your own types, you will need to make sure they can be saved and
39 | loaded correctly.
40 |
41 | Some types such as `boolean` may support additional options like:
42 |
43 | ```
44 | $m->addField('is_married', [
45 | 'type' => 'boolean',
46 | 'enum' => ['No', 'Yes'],
47 | ]);
48 |
49 | $m->set('is_married', 'Yes'); // normalizes into true
50 | $m->set('is_married', true); // better way because no need to normalize
51 |
52 | $m->save(); // stores as "Yes" because of type-casting
53 | ```
54 |
55 | ## Value types
56 |
57 | Any type can have a value of `null`:
58 |
59 | ```
60 | $m->set('is_married', null);
61 | if (!$m->get('is_married')) {
62 | // either null or false
63 | }
64 | ```
65 |
66 | If value is passed which is not compatible with field type, Agile Data will try
67 | to normalize value:
68 |
69 | ```
70 | $m->addField('age', ['type' => 'integer']);
71 | $m->addField('name', ['type' => 'string']);
72 |
73 | $m->set('age', '49.8');
74 | $m->set('name', ' John');
75 |
76 | echo $m->get('age'); // 49 - normalization cast value to integer
77 | echo $m->get('name'); // 'John' - normalization trims value
78 | ```
79 |
80 | ### Undefined type
81 |
82 | If you do not set type for a field, Agile Data will not normalize and type-cast
83 | its value.
84 |
85 | Because of the loose PHP types, you can encounter situations where undefined
86 | type is changed from `'4'` to `4`. This change is still considered "dirty".
87 |
88 | If you use numeric value with a type-less field, the response from SQL does
89 | not distinguish between integers and strings, so your value will be stored as
90 | "string" inside the model.
91 |
92 | The same can be said about forms, which submit all their data through POST
93 | request that has no types, so undefined type fields should work relatively
94 | good with the standard setup of Agile Data + Agile Toolkit + SQL.
95 |
96 | ### Type of IDs
97 |
98 | Many databases will allow you to use different types for ID fields.
99 | In SQL the 'id' column will usually be "integer", but sometimes it can be of
100 | a different type.
101 |
102 | ### Supported types
103 |
104 | - `string` - for storing short strings, such as name of a person. Normalize will trim the value.
105 | - `text` - for storing long strings, suchas notes or description. Normalize will trim the value.
106 | - `boolean` - normalize will cast value to boolean.
107 | - `smallint`, `integer`, `bigint` - normalize will cast value to integer.
108 | - `atk4_money` - normalize will round value with 4 digits after dot.
109 | - `float` - normalize will cast value to float.
110 | - `date` - normalize will convert value to DateTime object.
111 | - `datetime` - normalize will convert value to DateTime object.
112 | - `time` - normalize will convert value to DateTime object.
113 | - `json` - no normalization by default
114 | - `object` - no normalization by default
115 |
116 | ### Types and UI
117 |
118 | UI framework such as Agile Toolkit will typically rely on field type information
119 | to properly present data for views (forms and tables) without you having to
120 | explicitly specify the `ui` property.
121 |
122 | ## Serialization
123 |
124 | Some types cannot be stored natively. For example, generic objects and arrays
125 | have no native type in SQL database. This is where serialization feature is used.
126 |
127 | Field may use serialization to further encode field value for the storage purpose:
128 |
129 | ```
130 | $this->addField('private_key', [
131 | 'type' => 'object',
132 | 'system' => true,
133 | ]);
134 | ```
135 |
136 | ### Array and Object types
137 |
138 | Some types may require serialization for some persistencies, for instance types
139 | 'json' and 'object' cannot be stored in SQL natively. `json` type can be used
140 | to store these in JSON.
141 |
142 | This is handy when mapping JSON data into native PHP structures.
143 |
--------------------------------------------------------------------------------
/docs/types.md:
--------------------------------------------------------------------------------
1 | :::{php:namespace} Atk4\Data
2 | :::
3 |
4 | # Data Types
5 |
6 | ATK Data framework implements a consistent and extensible type system with the
7 | following goals:
8 |
9 | ## Type specification
10 |
11 | Mechanism to find corresponding class configuration based on selected type.
12 |
13 | Specifying one of supported types will ensure that your field format is
14 | recognized universally, can be stored, loaded, presented to user through UI
15 | inside a Table or Form and can be exported through RestAPI:
16 |
17 | ```
18 | $this->addField('is_vip', ['type' => 'boolean']);
19 | ```
20 |
21 | We also allow use of custom Field implementation:
22 |
23 | ```
24 | $this->addField('encrypted_password', new \Atk4\Data\Field\PasswordField());
25 | ```
26 |
27 | A properly implemented type will still be able to offer some means to present
28 | it in human-readable format, however in some cases, if you plan on using ATK UI,
29 | you would have to create a custom decorators/FormField to properly read and
30 | present your type value. See {php:attr}`Field::$ui`.
31 |
32 | ## Persistence mechanics and Serialization
33 |
34 | All type values can be specified as primitives. For example `DateTime` object
35 | class is associated with the `type=time` will be converted into string with
36 | default format of "21:43:05".
37 |
38 | Types that cannot be converted into primitive, there exist a process of "serialization",
39 | which can use JSON or standard serialize() method to store object inside
40 | incompatible database/persistence.
41 |
42 | Serialization abilities allow us to get rid of many arbitrary types such as "array_json"
43 | and simply use this:
44 |
45 | ```
46 | $model->addField('selection', ['type' => 'json']);
47 | ```
48 |
49 | ## Field configuration
50 |
51 | Fields can be further configured. For numeric fields it's possible to provide
52 | precision. For instance, when user specifies `'type' => 'atk4_money'` it is represented
53 | as `['Number', 'precision' => 2, 'prefix' => '€']`
54 |
55 | Not only this allows us make a flexible and re-usable functionality for fields,
56 | but also allows for an easy way to override:
57 |
58 | ```
59 | $model->addField('salary', ['type' => 'atk4_money', 'precision' => 4']);
60 | ```
61 |
62 | Although some configuration of the field may appear irrelevant (prefix/postfix)
63 | to operations with data from inside PHP, those properties can be used by
64 | ATK UI or data export routines to properly input or display values.
65 |
66 | ## Typecasting
67 |
68 | ATK Data uses PHP native types and classes. For example, 'time' type is using
69 | DateTime object.
70 |
71 | When storing or displaying a type-casting takes place which will format the
72 | value accordingly. Type-casting can be persistence-specific, for instance,
73 | when storing "datetime" into SQL, the ISO format will be used, but when displayed
74 | to the user a regional format is used instead.
75 |
76 | ## Supported Types
77 |
78 | ATK Data supports the following types:
79 |
80 | - string
81 | - boolean
82 | - integer
83 | - float
84 | - atk4_money
85 | - date ({php:class}`\DateTime`)
86 | - datetime ({php:class}`\DateTime`)
87 | - time ({php:class}`\DateTime`)
88 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | tests
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | src
18 | tests
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/Exception.php:
--------------------------------------------------------------------------------
1 | (T): mixed */
24 | public $expr;
25 |
26 | protected function init(): void
27 | {
28 | $this->_init();
29 |
30 | $this->ui['table']['sortable'] = false;
31 |
32 | $this->onHookToOwnerEntity(Model::HOOK_AFTER_LOAD, function (Model $entity) {
33 | $entity->getDataRef()[$this->shortName] = ($this->expr)($entity);
34 | });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Field/EmailField.php:
--------------------------------------------------------------------------------
1 | addField('email', [EmailField::class]);
15 | * $user->addField('email_mx_check', [EmailField::class, 'dnsCheck' => true]);
16 | * $user->addField('email_with_name', [EmailField::class, 'allowName' => true]);
17 | */
18 | class EmailField extends Field
19 | {
20 | /** @var bool Enable lookup for MX record for email addresses stored */
21 | public $dnsCheck = false;
22 |
23 | /** @var bool Allow display name as per RFC2822, eg. format like "Romans " */
24 | public $allowName = false;
25 |
26 | #[\Override]
27 | public function normalize($value)
28 | {
29 | $value = parent::normalize($value);
30 | if ($value === null) {
31 | return $value;
32 | }
33 |
34 | $email = trim($value);
35 | if ($this->allowName) {
36 | $email = preg_replace('~^[^<]*<([^>]*)>~', '\1', $email);
37 | }
38 |
39 | if (!str_contains($email, '@')) {
40 | throw new ValidationException([$this->shortName => 'Email address does not have domain'], $this->getOwner());
41 | }
42 |
43 | [$user, $domain] = explode('@', $email, 2);
44 | $domain = idn_to_ascii($domain, \IDNA_DEFAULT, \INTL_IDNA_VARIANT_UTS46); // always convert domain to ASCII
45 |
46 | if (!filter_var($user . '@' . $domain, \FILTER_VALIDATE_EMAIL)) {
47 | throw new ValidationException([$this->shortName => 'Email address format is invalid'], $this->getOwner());
48 | }
49 |
50 | if ($this->dnsCheck) {
51 | if (!$this->hasAnyDnsRecord($domain)) {
52 | throw new ValidationException([$this->shortName => 'Email address domain does not exist'], $this->getOwner());
53 | }
54 | }
55 |
56 | return parent::normalize($value);
57 | }
58 |
59 | /**
60 | * @param list $types
61 | */
62 | private function hasAnyDnsRecord(string $domain, array $types = ['MX', 'A', 'AAAA', 'CNAME']): bool
63 | {
64 | foreach (array_unique(array_map('strtoupper', $types)) as $t) {
65 | $dnsConsts = [
66 | 'MX' => \DNS_MX,
67 | 'A' => \DNS_A,
68 | 'AAAA' => \DNS_AAAA,
69 | 'CNAME' => \DNS_CNAME,
70 | ];
71 |
72 | $records = @dns_get_record($domain . '.', $dnsConsts[$t]);
73 | if ($records === false) { // retry once on failure
74 | $records = dns_get_record($domain . '.', $dnsConsts[$t]);
75 | }
76 | if ($records !== false && count($records) > 0) {
77 | return true;
78 | }
79 | }
80 |
81 | return false;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/Field/PasswordField.php:
--------------------------------------------------------------------------------
1 | 'string']))->normalize($password);
19 |
20 | if (!preg_match('~^\P{C}+$~u', $password) || $this->hashPasswordIsHashed($password)) {
21 | throw new Exception('Invalid password');
22 | } elseif (!$forVerifyOnly && mb_strlen($password) < $this->minLength) {
23 | throw new Exception('At least ' . $this->minLength . ' characters are required');
24 | }
25 |
26 | return $password;
27 | }
28 |
29 | public function hashPassword(string $password): string
30 | {
31 | $password = $this->normalizePassword($password, false);
32 |
33 | $hash = \password_hash($password, \PASSWORD_BCRYPT, ['cost' => 8]);
34 | $e = false;
35 | try {
36 | if (!$this->hashPasswordIsHashed($hash) || !$this->hashPasswordVerify($hash, $password)) {
37 | $e = null;
38 | }
39 | } catch (\Exception $e) {
40 | }
41 | if ($e !== false) {
42 | throw new Exception('Unexpected error when hashing password', 0, $e);
43 | }
44 |
45 | return $hash;
46 | }
47 |
48 | public function hashPasswordVerify(string $hash, string $password): bool
49 | {
50 | $hash = $this->normalize($hash);
51 | $password = $this->normalizePassword($password, true);
52 |
53 | return \password_verify($password, $hash);
54 | }
55 |
56 | public function hashPasswordIsHashed(string $value): bool
57 | {
58 | try {
59 | $value = parent::normalize($value) ?? '';
60 | } catch (\Exception $e) {
61 | }
62 |
63 | return \password_get_info($value)['algo'] === \PASSWORD_BCRYPT;
64 | }
65 |
66 | #[\Override]
67 | public function normalize($hash): ?string
68 | {
69 | $hash = parent::normalize($hash);
70 |
71 | if ($hash !== null && ($hash === '' || !$this->hashPasswordIsHashed($hash))) {
72 | throw new Exception('Invalid password hash');
73 | }
74 |
75 | return $hash;
76 | }
77 |
78 | public function setPassword(Model $entity, string $password): self
79 | {
80 | $this->set($entity, $this->hashPassword($password));
81 |
82 | return $this;
83 | }
84 |
85 | /**
86 | * Returns true if the supplied password matches the stored hash.
87 | */
88 | public function verifyPassword(Model $entity, string $password): bool
89 | {
90 | $v = $this->get($entity);
91 | if ($v === null) {
92 | throw (new Exception('Password hash is null, verification is impossible'))
93 | ->addMoreInfo('field', $this->shortName);
94 | }
95 |
96 | return $this->hashPasswordVerify($v, $password);
97 | }
98 |
99 | public function generatePassword(?int $length = null): string
100 | {
101 | $charsAll = array_diff(array_merge(
102 | range('0', '9'),
103 | range('a', 'z'),
104 | range('A', 'Z'),
105 | ), ['0', 'o', 'O', '1', 'l', 'i', 'I']);
106 |
107 | $resArr = [];
108 | for ($i = 0; $i < max(8, $length ?? $this->minLength); ++$i) {
109 | $chars = array_values(array_diff($charsAll, array_slice($resArr, -4)));
110 | $resArr[] = $chars[random_int(0, count($chars) - 1)];
111 | }
112 |
113 | return $this->normalizePassword(implode('', $resArr), false);
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/Field/SqlExpressionField.php:
--------------------------------------------------------------------------------
1 | (T, Expression): (string|Expressionable)|string|Expressionable Used expression. */
24 | public $expr;
25 |
26 | /** @var string Specifies how to aggregate this. */
27 | public $aggregate;
28 |
29 | /** @var string */
30 | public $concatSeparator;
31 |
32 | /** @var Reference\HasMany|null When defining as aggregate, this will point to relation object. */
33 | public $aggregateRelation;
34 |
35 | /** @var string Specifies which field to use. */
36 | public $field;
37 |
38 | #[\Override]
39 | public function useAlias(): bool
40 | {
41 | return true;
42 | }
43 |
44 | #[\Override]
45 | public function getDsqlExpression(Expression $expression): Expression
46 | {
47 | $expr = $this->expr;
48 | if ($expr instanceof \Closure) {
49 | $expr = $expr($this->getOwner(), $expression);
50 | }
51 |
52 | if (is_string($expr)) {
53 | // if our Model has expr() method (inherited from Persistence\Sql) then use it
54 | if ($this->getOwner()->hasMethod('expr')) {
55 | return $this->getOwner()->expr('([])', [$this->getOwner()->expr($expr)]);
56 | }
57 |
58 | // otherwise call it from expression itself
59 | return $expression->expr('([])', [$expression->expr($expr)]);
60 | } elseif ($expr instanceof Expressionable && !$expr instanceof Expression) { // @phpstan-ignore instanceof.alwaysTrue
61 | return $expression->expr('[]', [$expr]);
62 | }
63 |
64 | return $expr;
65 | }
66 |
67 | #[\Override]
68 | public function __debugInfo(): array
69 | {
70 | return array_merge(parent::__debugInfo(), [
71 | 'expr' => $this->expr,
72 | ]);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/Model/EntityFieldPair.php:
--------------------------------------------------------------------------------
1 | assertIsEntity();
30 |
31 | $this->entity = $entity;
32 | $this->fieldName = $fieldName;
33 | }
34 |
35 | /**
36 | * @return TModel
37 | */
38 | public function getModel(): Model
39 | {
40 | return $this->entity->getModel();
41 | }
42 |
43 | /**
44 | * @return TModel
45 | */
46 | public function getEntity(): Model
47 | {
48 | return $this->entity;
49 | }
50 |
51 | public function getFieldName(): string
52 | {
53 | return $this->fieldName;
54 | }
55 |
56 | /**
57 | * @phpstan-return TField
58 | */
59 | public function getField(): Field
60 | {
61 | $field = $this->getModel()->getField($this->getFieldName());
62 |
63 | return $field;
64 | }
65 |
66 | /**
67 | * @return mixed
68 | */
69 | public function get()
70 | {
71 | return $this->getEntity()->get($this->getFieldName());
72 | }
73 |
74 | /**
75 | * @param mixed $value
76 | */
77 | public function set($value): void
78 | {
79 | $this->getEntity()->set($this->getFieldName(), $value);
80 | }
81 |
82 | public function setNull(): void
83 | {
84 | $this->getEntity()->setNull($this->getFieldName());
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/Model/FieldPropertiesTrait.php:
--------------------------------------------------------------------------------
1 | |null For several types enum can provide list of available options. ['blue', 'red']. */
24 | public ?array $enum = null;
25 |
26 | /**
27 | * For fields that can be selected, values can represent interpretation of the values,
28 | * for instance ['F' => 'Female', 'M' => 'Male'].
29 | *
30 | * @var array|null
31 | */
32 | public ?array $values = null;
33 |
34 | /**
35 | * If value of this field is defined by a model, this property will contain reference link.
36 | */
37 | protected ?string $referenceLink = null;
38 |
39 | /** Is it system field? System fields are be always loaded and saved. */
40 | public bool $system = false;
41 |
42 | /** @var mixed Default value of field. */
43 | public $default;
44 |
45 | /**
46 | * Is field read only?
47 | * Field value may not be changed. It'll never be saved.
48 | * For example, expressions are read only.
49 | */
50 | public bool $readOnly = false;
51 |
52 | /**
53 | * Defines a label to go along with this field. Use getCaption() which
54 | * will always return meaningful label (even if caption is null). Set
55 | * this property to any string.
56 | *
57 | * @var string|null
58 | */
59 | public $caption;
60 |
61 | /**
62 | * Array with UI flags like editable, visible and hidden.
63 | *
64 | * By default hasOne relation ID field should be editable in forms,
65 | * but not visible in grids. UI should respect these flags.
66 | *
67 | * @var array
68 | */
69 | public array $ui = [];
70 | }
71 |
--------------------------------------------------------------------------------
/src/Model/JoinLinkTrait.php:
--------------------------------------------------------------------------------
1 | joinName !== null;
14 | }
15 |
16 | public function getJoin(): Join
17 | {
18 | return $this->getOwner()->getJoin($this->joinName);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Model/JoinsTrait.php:
--------------------------------------------------------------------------------
1 | The class used by join() method. */
15 | protected array $_defaultSeedJoin = [Join::class];
16 |
17 | /**
18 | * Creates an objects that describes relationship between multiple tables (or collections).
19 | *
20 | * When object is loaded, then instead of pulling all the data from a single table,
21 | * join will also query $foreignTable in order to find additional fields. When inserting
22 | * the record will be also added inside $foreignTable and relationship will be maintained.
23 | *
24 | * @param array $defaults
25 | */
26 | public function join(string $foreignTable, array $defaults = []): Join
27 | {
28 | $this->assertIsModel();
29 |
30 | $defaults[0] = $foreignTable;
31 |
32 | $join = Join::fromSeed($this->_defaultSeedJoin, $defaults);
33 |
34 | $name = $join->getDesiredName();
35 | if ($this->hasElement($name)) {
36 | throw (new Exception('Join with such name already exists'))
37 | ->addMoreInfo('name', $name)
38 | ->addMoreInfo('foreignTable', $foreignTable);
39 | }
40 |
41 | $this->add($join);
42 |
43 | return $join;
44 | }
45 |
46 | /**
47 | * Add left/weak join.
48 | *
49 | * @param array $defaults
50 | */
51 | public function leftJoin(string $foreignTable, array $defaults = []): Join
52 | {
53 | $defaults['weak'] = true;
54 |
55 | return $this->join($foreignTable, $defaults);
56 | }
57 |
58 | public function hasJoin(string $link): bool
59 | {
60 | return $this->getModel(true)->hasElement('#join-' . $link);
61 | }
62 |
63 | public function getJoin(string $link): Join
64 | {
65 | $this->assertIsModel();
66 |
67 | return $this->getElement('#join-' . $link);
68 | }
69 |
70 | /**
71 | * @return array
72 | */
73 | public function getJoins(): array
74 | {
75 | $this->assertIsModel();
76 |
77 | $res = [];
78 | foreach ($this->elements as $k => $v) {
79 | if (str_starts_with($k, '#join-')) {
80 | $link = substr($k, strlen('#join-'));
81 | $res[$link] = $this->getJoin($link);
82 | } elseif ($v instanceof Join) {
83 | throw new \Error('Unexpected Join index');
84 | }
85 | }
86 |
87 | return $res;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/Model/ReferencesTrait.php:
--------------------------------------------------------------------------------
1 | The seed used by addReference() method. */
17 | protected array $_defaultSeedAddReference = [Reference::class];
18 |
19 | /** @var array The seed used by hasOne() method. */
20 | protected array $_defaultSeedHasOne = [Reference\HasOne::class];
21 |
22 | /** @var array The seed used by hasMany() method. */
23 | protected array $_defaultSeedHasMany = [Reference\HasMany::class];
24 |
25 | /** @var array The seed used by containsOne() method. */
26 | protected array $_defaultSeedContainsOne = [Reference\ContainsOne::class];
27 |
28 | /** @var array The seed used by containsMany() method. */
29 | protected array $_defaultSeedContainsMany = [Reference\ContainsMany::class];
30 |
31 | /**
32 | * @param array $seed
33 | * @param array $defaults
34 | */
35 | protected function _addReference(array $seed, string $link, array $defaults): Reference
36 | {
37 | $this->assertIsModel();
38 |
39 | $defaults[0] = $link;
40 |
41 | $reference = Reference::fromSeed($seed, $defaults);
42 |
43 | $name = $reference->getDesiredName();
44 | if ($this->hasElement($name)) {
45 | throw (new Exception('Reference with such name already exists'))
46 | ->addMoreInfo('name', $name)
47 | ->addMoreInfo('link', $link);
48 | }
49 |
50 | $this->add($reference);
51 |
52 | return $reference;
53 | }
54 |
55 | /**
56 | * Add generic relation. Provide your own call-back that will return the model.
57 | *
58 | * @param array $defaults
59 | */
60 | public function addReference(string $link, array $defaults): Reference
61 | {
62 | return $this->_addReference($this->_defaultSeedAddReference, $link, $defaults);
63 | }
64 |
65 | /**
66 | * Add hasOne reference.
67 | *
68 | * @param array $defaults
69 | *
70 | * @return Reference\HasOne|Reference\HasOneSql
71 | */
72 | public function hasOne(string $link, array $defaults): Reference
73 | {
74 | return $this->_addReference($this->_defaultSeedHasOne, $link, $defaults); // @phpstan-ignore return.type
75 | }
76 |
77 | /**
78 | * Add hasMany reference.
79 | *
80 | * @param array $defaults
81 | *
82 | * @return Reference\HasMany
83 | */
84 | public function hasMany(string $link, array $defaults): Reference
85 | {
86 | return $this->_addReference($this->_defaultSeedHasMany, $link, $defaults); // @phpstan-ignore return.type
87 | }
88 |
89 | /**
90 | * Add containsOne reference.
91 | *
92 | * @param array $defaults
93 | *
94 | * @return Reference\ContainsOne
95 | */
96 | public function containsOne(string $link, array $defaults): Reference
97 | {
98 | return $this->_addReference($this->_defaultSeedContainsOne, $link, $defaults); // @phpstan-ignore return.type
99 | }
100 |
101 | /**
102 | * Add containsMany reference.
103 | *
104 | * @param array $defaults
105 | *
106 | * @return Reference\ContainsMany
107 | */
108 | public function containsMany(string $link, array $defaults): Reference
109 | {
110 | return $this->_addReference($this->_defaultSeedContainsMany, $link, $defaults); // @phpstan-ignore return.type
111 | }
112 |
113 | public function hasReference(string $link): bool
114 | {
115 | return $this->getModel(true)->hasElement('#ref-' . $link);
116 | }
117 |
118 | public function getReference(string $link): Reference
119 | {
120 | $this->assertIsModel();
121 |
122 | return $this->getElement('#ref-' . $link);
123 | }
124 |
125 | /**
126 | * @return array
127 | */
128 | public function getReferences(): array
129 | {
130 | $this->assertIsModel();
131 |
132 | $res = [];
133 | foreach ($this->elements as $k => $v) {
134 | if (str_starts_with($k, '#ref-')) {
135 | $link = substr($k, strlen('#ref-'));
136 | $res[$link] = $this->getReference($link);
137 | } elseif ($v instanceof Reference) {
138 | throw new \Error('Unexpected Reference index');
139 | }
140 | }
141 |
142 | return $res;
143 | }
144 |
145 | /**
146 | * Traverse reference and create their model.
147 | *
148 | * @param array $defaults
149 | */
150 | public function ref(string $link, array $defaults = []): Model
151 | {
152 | $reference = $this->getModel(true)->getReference($link);
153 |
154 | return $reference->ref($this, $defaults);
155 | }
156 |
157 | /**
158 | * Traverse reference and create their model but keep reference condition not materialized (for subquery actions).
159 | *
160 | * @param array $defaults
161 | */
162 | public function refLink(string $link, array $defaults = []): Model
163 | {
164 | $reference = $this->getReference($link);
165 |
166 | return $reference->refLink($defaults);
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/src/Model/Scope/AbstractScope.php:
--------------------------------------------------------------------------------
1 | _setOwner($owner);
38 | }
39 |
40 | /**
41 | * Method is executed when the scope is added to parent scope using Scope::add().
42 | */
43 | protected function init(): void
44 | {
45 | $this->_init();
46 |
47 | $this->onChangeModel();
48 | }
49 |
50 | abstract protected function onChangeModel(): void;
51 |
52 | /**
53 | * Get the model this condition is associated with.
54 | */
55 | public function getModel(): ?Model
56 | {
57 | return $this->issetOwner() ? $this->getOwner()->getModel() : null;
58 | }
59 |
60 | /**
61 | * Empty the scope object.
62 | *
63 | * @return $this
64 | */
65 | abstract public function clear(): self;
66 |
67 | /**
68 | * Negate the scope object e.g. from '=' to '!='.
69 | *
70 | * @return $this
71 | */
72 | abstract public function negate(): self;
73 |
74 | /**
75 | * Return if scope has any conditions.
76 | */
77 | abstract public function isEmpty(): bool;
78 |
79 | /**
80 | * Convert the scope to human readable words when applied on $model.
81 | */
82 | abstract public function toWords(?Model $model = null): string;
83 |
84 | /**
85 | * Simplifies by peeling off nested group conditions with single contained component.
86 | * Useful for converting (((field = value))) to field = value.
87 | */
88 | public function simplify(): self
89 | {
90 | return $this;
91 | }
92 |
93 | /**
94 | * Returns if scope contains several conditions.
95 | */
96 | public function isCompound(): bool
97 | {
98 | return false;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/Model/Scope/RootScope.php:
--------------------------------------------------------------------------------
1 | assertIsModel();
30 |
31 | if (($this->model ?? null) !== $model) {
32 | $this->model = $model;
33 |
34 | $this->onChangeModel();
35 | }
36 |
37 | return $this;
38 | }
39 |
40 | #[\Override]
41 | public function getModel(): Model
42 | {
43 | return $this->model;
44 | }
45 |
46 | #[\Override]
47 | public function negate(): self
48 | {
49 | throw new Exception('Model root scope cannot be negated');
50 | }
51 |
52 | /**
53 | * @return Model\Scope
54 | */
55 | public static function createAnd(...$conditions) // @phpstan-ignore method.missingOverride
56 | {
57 | return (parent::class)::createAnd(...$conditions);
58 | }
59 |
60 | /**
61 | * @return Model\Scope
62 | */
63 | public static function createOr(...$conditions) // @phpstan-ignore method.missingOverride
64 | {
65 | return (parent::class)::createOr(...$conditions);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Persistence/Array_/Action/RenameColumnIterator.php:
--------------------------------------------------------------------------------
1 | , \Traversable>>
13 | */
14 | class RenameColumnIterator extends \IteratorIterator
15 | {
16 | /** @var string */
17 | protected $origName;
18 | /** @var string */
19 | protected $newName;
20 |
21 | /**
22 | * @param \Traversable> $iterator
23 | */
24 | public function __construct(\Traversable $iterator, string $origName, string $newName)
25 | {
26 | parent::__construct($iterator);
27 |
28 | $this->origName = $origName;
29 | $this->newName = $newName;
30 | }
31 |
32 | #[\Override]
33 | public function current(): array
34 | {
35 | $row = parent::current();
36 |
37 | $keys = array_keys($row);
38 | $index = array_search($this->origName, $keys, true);
39 | if ($index === false) {
40 | throw (new Exception('Column not found'))
41 | ->addMoreInfo('orig_name', $this->origName);
42 | }
43 | $keys[$index] = $this->newName;
44 |
45 | return array_combine($keys, $row);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Persistence/Array_/Db/Row.php:
--------------------------------------------------------------------------------
1 | */
17 | private $data = [];
18 |
19 | public function __construct(Table $owner)
20 | {
21 | $this->owner = $owner;
22 | $this->rowIndex = self::getNextRowIndex();
23 | }
24 |
25 | public static function getNextRowIndex(): int
26 | {
27 | return ++self::$nextRowIndex;
28 | }
29 |
30 | /**
31 | * @return array
32 | */
33 | public function __debugInfo(): array
34 | {
35 | return [
36 | 'row_index' => $this->getRowIndex(),
37 | 'data' => $this->getData(),
38 | ];
39 | }
40 |
41 | public function getOwner(): Table
42 | {
43 | return $this->owner;
44 | }
45 |
46 | public function getRowIndex(): int
47 | {
48 | return $this->rowIndex;
49 | }
50 |
51 | /**
52 | * @return mixed
53 | */
54 | public function getValue(string $columnName)
55 | {
56 | return $this->data[$columnName];
57 | }
58 |
59 | /**
60 | * @return array
61 | */
62 | public function getData(): array
63 | {
64 | return $this->data;
65 | }
66 |
67 | protected function initValue(string $columnName): void
68 | {
69 | $this->data[$columnName] = null;
70 | }
71 |
72 | /**
73 | * @param array $data
74 | */
75 | public function updateValues(array $data): void
76 | {
77 | $owner = $this->getOwner();
78 |
79 | $newData = [];
80 | foreach ($data as $columnName => $newValue) {
81 | $owner->assertHasColumnName($columnName);
82 | if ($newValue !== $this->data[$columnName]) {
83 | $newData[$columnName] = $newValue;
84 | }
85 | }
86 |
87 | $that = $this;
88 | \Closure::bind(static function () use ($owner, $that, $newData) {
89 | $owner->beforeValuesSet($that, $newData);
90 | }, null, $owner)();
91 |
92 | foreach ($newData as $columnName => $newValue) {
93 | $this->data[$columnName] = $newValue;
94 | }
95 | }
96 |
97 | protected function beforeDelete(): void
98 | {
99 | $this->updateValues(array_map(static function () {
100 | return null;
101 | }, $this->data));
102 |
103 | $this->owner = null; // @phpstan-ignore assign.propertyType
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/Persistence/Array_/Join.php:
--------------------------------------------------------------------------------
1 | getOwner();
17 |
18 | $foreignId = $entity->getDataRef()[$this->masterField];
19 | if ($foreignId === null) {
20 | return;
21 | }
22 |
23 | try {
24 | $foreignData = Persistence\Array_::assertInstanceOf($model->getPersistence())
25 | ->load($this->createFakeForeignModel(), $foreignId);
26 | } catch (Exception $e) {
27 | throw (new Exception('Unable to load joined record', $e->getCode(), $e))
28 | ->addMoreInfo('table', $this->foreignTable)
29 | ->addMoreInfo('id', $foreignId);
30 | }
31 |
32 | $dataRef = &$entity->getDataRef();
33 | foreach ($model->getFields() as $field) {
34 | if ($field->hasJoin() && $field->getJoin()->shortName === $this->shortName) {
35 | $dataRef[$field->shortName] = $foreignData[$field->getPersistenceName()];
36 | }
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Persistence/GenericPlatform.php:
--------------------------------------------------------------------------------
1 | createNotSupportedException();
30 | }
31 |
32 | #[\Override]
33 | public function getBigIntTypeDeclarationSQL(array $columnDef): string
34 | {
35 | throw $this->createNotSupportedException();
36 | }
37 |
38 | #[\Override]
39 | public function getBlobTypeDeclarationSQL(array $field): string
40 | {
41 | throw $this->createNotSupportedException();
42 | }
43 |
44 | #[\Override]
45 | public function getBooleanTypeDeclarationSQL(array $columnDef): string
46 | {
47 | throw $this->createNotSupportedException();
48 | }
49 |
50 | #[\Override]
51 | public function getClobTypeDeclarationSQL(array $field): string
52 | {
53 | throw $this->createNotSupportedException();
54 | }
55 |
56 | #[\Override]
57 | public function getIntegerTypeDeclarationSQL(array $columnDef): string
58 | {
59 | throw $this->createNotSupportedException();
60 | }
61 |
62 | #[\Override]
63 | public function getSmallIntTypeDeclarationSQL(array $columnDef): string
64 | {
65 | throw $this->createNotSupportedException();
66 | }
67 |
68 | #[\Override]
69 | public function getCurrentDatabaseExpression(): string
70 | {
71 | throw $this->createNotSupportedException();
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/BinaryStringCompatibilityTypecastTrait.php:
--------------------------------------------------------------------------------
1 | binaryStringGetPrefixConst() . hash('crc32b', $hex) . $hex;
22 | }
23 |
24 | private function binaryStringIsEncoded(string $value): bool
25 | {
26 | return str_starts_with($value, $this->binaryStringGetPrefixConst());
27 | }
28 |
29 | private function binaryStringDecode(string $value): string
30 | {
31 | if (!$this->binaryStringIsEncoded($value)) {
32 | throw new Exception('Unexpected unencoded binary value');
33 | }
34 |
35 | $prefixLength = strlen($this->binaryStringGetPrefixConst());
36 | $hexCrc = substr($value, $prefixLength, 8);
37 | $hex = substr($value, $prefixLength + 8);
38 | if ((strlen($hex) % 2) !== 0 || $hexCrc !== hash('crc32b', $hex)) {
39 | throw new Exception('Unexpected binary value crc');
40 | }
41 |
42 | $res = hex2bin($hex);
43 | if ($this->binaryStringIsEncoded($res)) {
44 | throw new Exception('Unexpected double encoded binary value');
45 | }
46 |
47 | return $res;
48 | }
49 |
50 | private function binaryStringIsEncodeNeeded(string $type): bool
51 | {
52 | return $this->getDatabasePlatform() instanceof OraclePlatform
53 | && in_array($type, ['binary', 'blob'], true);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Exception.php:
--------------------------------------------------------------------------------
1 | getParams()['query'];
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/ExplicitCastCompatibilityTypecastTrait.php:
--------------------------------------------------------------------------------
1 | explicitCastGetPrefixConst() . $type . "\r" . $value;
21 | }
22 |
23 | private function explicitCastIsEncoded(string $value): bool
24 | {
25 | return str_starts_with($value, $this->explicitCastGetPrefixConst());
26 | }
27 |
28 | private function explicitCastIsEncodedBinary(string $value): bool
29 | {
30 | if (!$this->explicitCastIsEncoded($value)) {
31 | return false;
32 | }
33 |
34 | $type = $this->explicitCastDecodeType($value);
35 |
36 | return in_array($type, ['binary', 'blob'], true);
37 | }
38 |
39 | private function explicitCastDecodeType(string $value): string
40 | {
41 | if (!$this->explicitCastIsEncoded($value)) {
42 | throw new Exception('Unexpected unencoded value');
43 | }
44 |
45 | $prefixLength = strlen($this->explicitCastGetPrefixConst());
46 | $nextCrPos = strpos($value, "\r", $prefixLength);
47 | if ($nextCrPos === false) {
48 | throw new Exception('Unexpected encoded value format');
49 | }
50 |
51 | $type = substr($value, $prefixLength, $nextCrPos - $prefixLength);
52 |
53 | return $type;
54 | }
55 |
56 | private function explicitCastDecode(string $value): string
57 | {
58 | $resPos = strlen($this->explicitCastGetPrefixConst()) + strlen($this->explicitCastDecodeType($value)) + 1;
59 | $res = substr($value, $resPos);
60 |
61 | if ($this->explicitCastIsEncoded($res)) {
62 | throw new Exception('Unexpected double encoded value');
63 | }
64 |
65 | return $res;
66 | }
67 |
68 | private function explicitCastIsEncodeNeeded(string $type): bool
69 | {
70 | $platform = $this->getDatabasePlatform();
71 | if ($platform instanceof PostgreSQLPlatform
72 | && in_array($type, ['binary', 'blob', 'json', 'date', 'time', 'datetime'], true) // every string type other than case insensitive text
73 | ) {
74 | return true;
75 | } elseif ($platform instanceof SQLServerPlatform
76 | && in_array($type, ['binary', 'blob'], true)
77 | ) {
78 | return true;
79 | }
80 |
81 | return false;
82 | }
83 |
84 | /**
85 | * @param scalar $value
86 | */
87 | private function explicitCastIsDecodeNeeded(string $type, $value): bool
88 | {
89 | return $this->explicitCastIsEncodeNeeded($type)
90 | && $this->explicitCastIsEncoded($value);
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Expressionable.php:
--------------------------------------------------------------------------------
1 | getOwner()->persistenceData['use_table_prefixes'] = true;
26 |
27 | // our short name will be unique
28 | // TODO this should be removed, short name is not guaranteed to be unique with nested model/query
29 | if ($this->foreignAlias === null) {
30 | $this->foreignAlias = ($this->getOwner()->tableAlias ?? '') . '_' . (str_starts_with($this->shortName, '#join-') ? substr($this->shortName, 6) : $this->shortName);
31 | }
32 |
33 | // TODO this mutates the owner model/joins!
34 | if (!$this->reverse && !$this->getOwner()->hasField($this->masterField)) {
35 | $owner = $this->hasJoin() ? $this->getJoin() : $this->getOwner();
36 | $field = $owner->addField($this->masterField, ['type' => 'bigint', 'system' => true, 'readOnly' => true]);
37 | $this->masterField = $field->shortName; // TODO this mutates the join!
38 | } elseif ($this->reverse && !$this->getOwner()->hasField($this->foreignField) && $this->hasJoin()) {
39 | $owner = $this->getJoin();
40 | $field = $owner->addField($this->foreignField, ['type' => 'bigint', 'system' => true, 'readOnly' => true, 'actual' => $this->masterField]);
41 | $this->foreignField = $field->shortName; // TODO this mutates the join!
42 | }
43 | }
44 |
45 | #[\Override]
46 | protected function initJoinHooks(): void
47 | {
48 | parent::initJoinHooks();
49 |
50 | $this->onHookToOwnerModel(Persistence\Sql::HOOK_INIT_SELECT_QUERY, \Closure::fromCallable([$this, 'initSelectQuery']));
51 | }
52 |
53 | /**
54 | * Before query is executed, this method will be called.
55 | */
56 | protected function initSelectQuery(Model $model, Query $query): void
57 | {
58 | if ($this->on) {
59 | $onExpr = $this->on instanceof Expressionable ? $this->on : $this->getOwner()->expr($this->on);
60 | } else {
61 | $onExpr = $this->getOwner()->expr('{{}}.{} = {}', [
62 | $this->foreignAlias ?? $this->foreignTable,
63 | $this->foreignField,
64 | $this->hasJoin()
65 | ? $this->getOwner()->expr('{}.{}', [$this->getJoin()->foreignAlias, $this->masterField])
66 | : $this->getOwner()->getField($this->masterField),
67 | ]);
68 | }
69 |
70 | $query->join(
71 | $this->foreignTable,
72 | $onExpr,
73 | $this->kind,
74 | $this->foreignAlias
75 | );
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/MaterializedField.php:
--------------------------------------------------------------------------------
1 | getOwner()->assertIsModel($context);
23 |
24 | $this->field = $field;
25 | }
26 |
27 | #[\Override]
28 | public function getDsqlExpression(Expression $expression): Expression
29 | {
30 | return $expression->expr('{}', [$this->field->shortName]);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Mssql/Connection.php:
--------------------------------------------------------------------------------
1 | */
16 | private $requireCommentHintTypes = [
17 | 'text',
18 | ];
19 |
20 | #[\Override]
21 | public function getVarcharTypeDeclarationSQL(array $column)
22 | {
23 | $column['length'] = ($column['length'] ?? 255) * 4;
24 |
25 | return parent::getVarcharTypeDeclarationSQL($column);
26 | }
27 |
28 | // remove once https://github.com/doctrine/dbal/pull/4987 is fixed
29 | #[\Override]
30 | public function getClobTypeDeclarationSQL(array $column)
31 | {
32 | $res = parent::getClobTypeDeclarationSQL($column);
33 |
34 | return (str_starts_with($res, 'VARCHAR') ? 'N' : '') . $res;
35 | }
36 |
37 | #[\Override]
38 | public function getCurrentDatabaseExpression(bool $includeSchema = false): string
39 | {
40 | if ($includeSchema) {
41 | return 'CONCAT(DB_NAME(), \'.\', SCHEMA_NAME())';
42 | }
43 |
44 | return parent::getCurrentDatabaseExpression();
45 | }
46 |
47 | #[\Override]
48 | public function getCreateIndexSQL(Index $index, $table)
49 | {
50 | // workaround https://github.com/doctrine/dbal/issues/5507
51 | // no side effect on DBAL index list observed, but multiple null values cannot be inserted
52 | // the only, very complex, solution would be using intermediate view
53 | // SQL Server should be fixed to allow FK creation when there is an unique index
54 | // with "WHERE xxx IS NOT NULL" as FK does not restrict NULL values anyway
55 | return $index->hasFlag('atk4-not-null')
56 | ? AbstractPlatform::getCreateIndexSQL($index, $table)
57 | : parent::getCreateIndexSQL($index, $table);
58 | }
59 |
60 | // SQL Server DBAL platform has buggy identifier escaping, fix until fixed officially, see:
61 | // https://github.com/doctrine/dbal/pull/6353
62 |
63 | private function unquoteSingleIdentifier(string $possiblyQuotedName): string
64 | {
65 | return str_starts_with($possiblyQuotedName, '[') && str_ends_with($possiblyQuotedName, ']')
66 | ? substr($possiblyQuotedName, 1, -1)
67 | : $possiblyQuotedName;
68 | }
69 |
70 | #[\Override]
71 | protected function getCreateColumnCommentSQL($tableName, $columnName, $comment)
72 | {
73 | if (str_contains($tableName, '.')) {
74 | [$schemaName, $tableName] = explode('.', $tableName);
75 | } else {
76 | $schemaName = 'dbo';
77 | }
78 |
79 | return $this->getAddExtendedPropertySQL( // @phpstan-ignore method.internal
80 | 'MS_Description',
81 | $comment,
82 | 'SCHEMA',
83 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($schemaName)),
84 | 'TABLE',
85 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($tableName)),
86 | 'COLUMN',
87 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($columnName)),
88 | );
89 | }
90 |
91 | #[\Override]
92 | protected function getAlterColumnCommentSQL($tableName, $columnName, $comment)
93 | {
94 | if (str_contains($tableName, '.')) {
95 | [$schemaName, $tableName] = explode('.', $tableName);
96 | } else {
97 | $schemaName = 'dbo';
98 | }
99 |
100 | return $this->getUpdateExtendedPropertySQL( // @phpstan-ignore method.internal
101 | 'MS_Description',
102 | $comment,
103 | 'SCHEMA',
104 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($schemaName)),
105 | 'TABLE',
106 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($tableName)),
107 | 'COLUMN',
108 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($columnName)),
109 | );
110 | }
111 |
112 | #[\Override]
113 | protected function getDropColumnCommentSQL($tableName, $columnName)
114 | {
115 | if (str_contains($tableName, '.')) {
116 | [$schemaName, $tableName] = explode('.', $tableName);
117 | } else {
118 | $schemaName = 'dbo';
119 | }
120 |
121 | return $this->getDropExtendedPropertySQL( // @phpstan-ignore method.internal
122 | 'MS_Description',
123 | 'SCHEMA',
124 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($schemaName)),
125 | 'TABLE',
126 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($tableName)),
127 | 'COLUMN',
128 | $this->quoteStringLiteral($this->unquoteSingleIdentifier($columnName)),
129 | );
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Mysql/Connection.php:
--------------------------------------------------------------------------------
1 | getDatabasePlatform() instanceof MySQLPlatform);
18 |
19 | // active server connection is required, but nothing is sent to the server
20 | return $connection->getConnection()->getWrappedConnection()->getServerVersion(); // @phpstan-ignore method.deprecated, method.notFound
21 | }
22 |
23 | public static function isServerMariaDb(BaseConnection $connection): bool
24 | {
25 | return preg_match('~(?
30 | */
31 | public static function getServerMinorVersion(BaseConnection $connection): int
32 | {
33 | preg_match('~(\d+)\.(\d+)\.~', self::_getServerVersion($connection), $matches);
34 |
35 | return (int) $matches[1] * 100 + (int) $matches[2];
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Mysql/Expression.php:
--------------------------------------------------------------------------------
1 | $v) {
14 | if (($i % 2) === 1) {
15 | $parts[] = 'x\'' . bin2hex($v) . '\'';
16 | } elseif ($v !== '') {
17 | $parts[] = '\'' . str_replace(['\'', '\\'], ['\'\'', '\\\\'], $v) . '\'';
18 | }
19 | }
20 |
21 | if ($parts === []) {
22 | $parts = ['\'\''];
23 | }
24 |
25 | return $this->makeNaryTree($parts, 10, static function (array $parts) {
26 | if (count($parts) === 1) {
27 | return reset($parts);
28 | }
29 |
30 | return 'concat(' . implode(', ', $parts) . ')';
31 | });
32 | }
33 |
34 | #[\Override]
35 | protected function hasNativeNamedParamSupport(): bool
36 | {
37 | $dbalConnection = $this->connection->getConnection();
38 |
39 | return !$dbalConnection->getNativeConnection() instanceof \mysqli;
40 | }
41 |
42 | #[\Override]
43 | protected function updateRenderBeforeExecute(array $render): array
44 | {
45 | [$sql, $params] = $render;
46 |
47 | $sql = preg_replace_callback(
48 | '~' . self::QUOTED_TOKEN_REGEX . '\K|:\w+~',
49 | static function ($matches) use ($params) {
50 | if ($matches[0] === '') {
51 | return '';
52 | }
53 |
54 | $sql = $matches[0];
55 | $value = $params[$sql];
56 |
57 | // emulate bind param support for float type
58 | // TODO open php-src feature request
59 | if (is_float($value)) {
60 | $sql = '(' . $sql . ' + 0.00)';
61 | }
62 |
63 | return $sql;
64 | },
65 | $sql
66 | );
67 |
68 | return parent::updateRenderBeforeExecute([$sql, $params]);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Mysql/Query.php:
--------------------------------------------------------------------------------
1 | connection)
24 | && Connection::getServerMinorVersion($this->connection) < 600;
25 |
26 | if ($isMysql5x) {
27 | $replaceSqlFx = function (string $sql, string $search, string $replacement) {
28 | return 'replace(' . $sql . ', ' . $this->escapeStringLiteral($search) . ', ' . $this->escapeStringLiteral($replacement) . ')';
29 | };
30 |
31 | // workaround missing regexp_replace() function
32 | $sqlRightEscaped = $sqlRight;
33 | foreach (['\\', '_', '%'] as $v) {
34 | $sqlRightEscaped = $replaceSqlFx($sqlRightEscaped, '\\' . $v, '\\' . $v . '*');
35 | }
36 | $sqlRightEscaped = $replaceSqlFx($sqlRightEscaped, '\\', '\\\\');
37 | foreach (['_', '%', '\\'] as $v) {
38 | $sqlRightEscaped = $replaceSqlFx($sqlRightEscaped, '\\\\' . str_replace('\\', '\\\\', $v) . '*', '\\' . $v);
39 | }
40 |
41 | // workaround https://bugs.mysql.com/bug.php?id=84118
42 | // https://bugs.mysql.com/bug.php?id=63829
43 | // https://bugs.mysql.com/bug.php?id=68901
44 | // https://www.db-fiddle.com/f/argVwuJuqjFAALqfUSTEJb/0
45 | $sqlRightEscaped = $replaceSqlFx($sqlRightEscaped, '%\\', '%\\\\');
46 | } else {
47 | $sqlRightEscaped = 'regexp_replace(' . $sqlRight . ', '
48 | . $this->escapeStringLiteral('\\\\\\\|\\\(?![_%])') . ', '
49 | . $this->escapeStringLiteral('\\\\\\\\') . ')';
50 | }
51 |
52 | return $sqlLeft . ($negated ? ' not' : '') . ' like ' . $sqlRightEscaped
53 | . ' escape ' . $this->escapeStringLiteral('\\');
54 | }
55 |
56 | #[\Override]
57 | protected function _renderConditionRegexpOperator(bool $negated, string $sqlLeft, string $sqlRight, bool $binary = false): string
58 | {
59 | $isMysql5x = !Connection::isServerMariaDb($this->connection)
60 | && Connection::getServerMinorVersion($this->connection) < 600;
61 |
62 | return $sqlLeft . ($negated ? ' not' : '') . ' regexp ' . (
63 | $isMysql5x
64 | ? 'concat(' . $this->escapeStringLiteral('@?') . ', ' . $sqlRight . ')' // https://dbfiddle.uk/diAepf8V
65 | : 'concat(' . $this->escapeStringLiteral('(?s)') . ', ' . $sqlRight . ')'
66 | );
67 | }
68 |
69 | #[\Override]
70 | public function groupConcat($field, string $separator = ',')
71 | {
72 | return $this->expr('group_concat({} separator ' . $this->escapeStringLiteral($separator) . ')', [$field]);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Oracle/Connection.php:
--------------------------------------------------------------------------------
1 | setMiddlewares([
21 | ...$configuration->getMiddlewares(),
22 | new InitializeSessionMiddleware(),
23 | ]);
24 |
25 | return $configuration;
26 | }
27 |
28 | #[\Override]
29 | public function lastInsertId(?string $sequence = null): string
30 | {
31 | if ($sequence) {
32 | return $this->dsql()->field($this->expr('{{}}.CURRVAL', [$sequence]))->getOne();
33 | }
34 |
35 | return parent::lastInsertId($sequence);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Oracle/Expression.php:
--------------------------------------------------------------------------------
1 | $dateFormat,
38 | 'NLS_TIME_FORMAT' => $timeFormat,
39 | 'NLS_TIMESTAMP_FORMAT' => $dateFormat . ' ' . $timeFormat,
40 | 'NLS_TIME_TZ_FORMAT' => $timeFormat . ' ' . $tzFormat,
41 | 'NLS_TIMESTAMP_TZ_FORMAT' => $dateFormat . ' ' . $timeFormat . ' ' . $tzFormat,
42 | 'NLS_NUMERIC_CHARACTERS' => '.,',
43 | 'NLS_COMP' => 'LINGUISTIC',
44 | 'NLS_SORT' => 'BINARY_CI',
45 | ] as $k => $v) {
46 | $vars[] = $k . " = '" . $v . "'";
47 | }
48 |
49 | $connection->exec('ALTER SESSION SET ' . implode(' ', $vars));
50 |
51 | return $connection;
52 | }
53 | };
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Oracle/SchemaManagerTrait.php:
--------------------------------------------------------------------------------
1 | _conn->executeQuery($sql, ['OWNER' => $databaseName]);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/PlatformFixColumnCommentTypeHintTrait.php:
--------------------------------------------------------------------------------
1 | type = $type;
31 | $this->requireCommentHint = $requireCommentHint;
32 | }
33 |
34 | #[\Override]
35 | public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
36 | {
37 | return $this->type->getSQLDeclaration($column, $platform);
38 | }
39 |
40 | #[\Override]
41 | public function getName(): string
42 | {
43 | return $this->type->getName();
44 | }
45 |
46 | #[\Override]
47 | public function requiresSQLCommentHint(AbstractPlatform $platform): bool
48 | {
49 | if ($this->requireCommentHint) {
50 | return true;
51 | }
52 |
53 | return $this->type->requiresSQLCommentHint($platform);
54 | }
55 | };
56 | $tmpType->setData(
57 | $column->getType(),
58 | in_array($column->getType()->getName(), $this->requireCommentHintTypes, true)
59 | );
60 |
61 | $columnWithTmpType = clone $column;
62 | $columnWithTmpType->setType($tmpType);
63 |
64 | return parent::getColumnComment($columnWithTmpType);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Postgresql/Connection.php:
--------------------------------------------------------------------------------
1 | setMiddlewares([
21 | ...$configuration->getMiddlewares(),
22 | new InitializeSessionMiddleware(),
23 | ]);
24 |
25 | return $configuration;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Postgresql/Expression.php:
--------------------------------------------------------------------------------
1 | newInstanceWithoutConstructor();
17 | if (\Closure::bind(static fn () => $dummyPersistence->explicitCastIsEncodedBinary($value), null, Persistence\Sql::class)()) {
18 | $value = \Closure::bind(static fn () => $dummyPersistence->explicitCastDecode($value), null, Persistence\Sql::class)();
19 |
20 | return 'decode(\'' . bin2hex($value) . '\', \'hex\')';
21 | }
22 |
23 | $parts = [];
24 | foreach (preg_split('~((?:\x00+[^\x00]{1,100})*\x00+)~', $value, -1, \PREG_SPLIT_DELIM_CAPTURE) as $i => $v) {
25 | if (($i % 2) === 1) {
26 | // will raise SQL error, PostgreSQL does not support \0 character
27 | $parts[] = 'convert_from(decode(\'' . bin2hex($v) . '\', \'hex\'), \'UTF8\')';
28 | } elseif ($v !== '') {
29 | // workaround https://github.com/php/php-src/issues/13958
30 | foreach (preg_split('~(\\\+)(?=\'|$)~', $v, -1, \PREG_SPLIT_DELIM_CAPTURE) as $i2 => $v2) {
31 | if (($i2 % 2) === 1) {
32 | $parts[] = strlen($v2) === 1
33 | ? 'chr(' . ord('\\') . ')'
34 | : 'repeat(chr(' . ord('\\') . '), ' . strlen($v2) . ')';
35 | } elseif ($v2 !== '') {
36 | $parts[] = '\'' . str_replace('\'', '\'\'', $v2) . '\'';
37 | }
38 | }
39 | }
40 | }
41 |
42 | if ($parts === []) {
43 | $parts = ['\'\''];
44 | }
45 |
46 | return $this->makeNaryTree($parts, 10, static function (array $parts) {
47 | if (count($parts) === 1) {
48 | return reset($parts);
49 | }
50 |
51 | return 'concat(' . implode(', ', $parts) . ')';
52 | });
53 | }
54 |
55 | #[\Override]
56 | protected function updateRenderBeforeExecute(array $render): array
57 | {
58 | [$sql, $params] = parent::updateRenderBeforeExecute($render);
59 |
60 | $sql = preg_replace_callback(
61 | '~' . self::QUOTED_TOKEN_REGEX . '\K|(?newInstanceWithoutConstructor();
80 | if (\Closure::bind(static fn () => $dummyPersistence->explicitCastIsEncoded($value), null, Persistence\Sql::class)()) {
81 | if (\Closure::bind(static fn () => $dummyPersistence->explicitCastIsEncodedBinary($value), null, Persistence\Sql::class)()) {
82 | $sql = 'cast(' . $sql . ' as bytea)';
83 | } else {
84 | $typeString = \Closure::bind(static fn () => $dummyPersistence->explicitCastDecodeType($value), null, Persistence\Sql::class)();
85 | $type = Type::getType($typeString);
86 | $dbType = $type->getSQLDeclaration([], $this->connection->getDatabasePlatform());
87 | $sql = 'cast(' . $sql . ' as ' . $dbType . ')';
88 | }
89 | } else {
90 | $sql = 'cast(' . $sql . ' as citext)';
91 | }
92 | } else {
93 | $sql = 'cast(' . $sql . ' as unknown)';
94 | }
95 |
96 | return $sql;
97 | },
98 | $sql
99 | );
100 |
101 | return [$sql, $params];
102 | }
103 |
104 | #[\Override]
105 | protected function _executeStatement(Statement $statement, bool $fromExecuteStatement)
106 | {
107 | $sql = \Closure::bind(static fn () => $statement->sql, null, Statement::class)();
108 | if (preg_match('~^\s*+select(?=\s|$)~i', $sql)) {
109 | return parent::_executeStatement($statement, $fromExecuteStatement);
110 | }
111 |
112 | return $this->connection->atomic(function () use ($statement, $fromExecuteStatement) {
113 | return parent::_executeStatement($statement, $fromExecuteStatement);
114 | });
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Postgresql/InitializeSessionMiddleware.php:
--------------------------------------------------------------------------------
1 | query('SELECT to_regtype(\'citext\')')->fetchOne() === null) {
31 | // "CREATE EXTENSION IF NOT EXISTS ..." cannot be used as it requires
32 | // CREATE privilege even if the extension is already installed
33 | $connection->query('CREATE EXTENSION citext');
34 | }
35 |
36 | return $connection;
37 | }
38 | };
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Postgresql/Query.php:
--------------------------------------------------------------------------------
1 | _renderConditionBinaryReuse(
29 | $sqlLeft,
30 | $sqlRight,
31 | function ($sqlLeft, $sqlRight) use ($makeSqlFx) {
32 | $iifByteaSqlFx = function ($valueSql, $trueSql, $falseSql) {
33 | return 'case when pg_typeof(' . $valueSql . ') = ' . $this->escapeStringLiteral('bytea') . '::regtype'
34 | . ' then ' . $trueSql . ' else ' . $falseSql . ' end';
35 | };
36 |
37 | $escapeNonUtf8Fx = function ($sql, $neverBytea = false) use ($iifByteaSqlFx) {
38 | $doubleBackslashesFx = function ($sql) {
39 | return 'replace(' . $sql . ', ' . $this->escapeStringLiteral('\\')
40 | . ', ' . $this->escapeStringLiteral('\\\\') . ')';
41 | };
42 |
43 | $byteaSql = 'cast(' . $doubleBackslashesFx('cast(' . $sql . ' as text)') . ' as bytea)';
44 | if (!$neverBytea) {
45 | $byteaSql = $iifByteaSqlFx(
46 | $sql,
47 | 'decode(' . $iifByteaSqlFx(
48 | $sql,
49 | $doubleBackslashesFx('substring(cast(' . $sql . ' as text) from 3)'),
50 | $this->escapeStringLiteral('')
51 | ) . ', ' . $this->escapeStringLiteral('hex') . ')',
52 | $byteaSql
53 | );
54 | }
55 |
56 | // 0x00 and 0x80+ bytes will be escaped as "\xddd"
57 | $res = 'encode(' . $byteaSql . ', ' . $this->escapeStringLiteral('escape') . ')';
58 |
59 | // replace backslash in "\xddd" for LIKE/REGEXP
60 | $res = 'regexp_replace(' . $res . ', '
61 | . $this->escapeStringLiteral('(?escapeStringLiteral("\\1\u{00a9}\\3\u{00a9}") . ', '
63 | . $this->escapeStringLiteral('g') . ')';
64 |
65 | // revert double backslashes
66 | $res = 'replace(' . $res . ', ' . $this->escapeStringLiteral('\\\\')
67 | . ', ' . $this->escapeStringLiteral('\\') . ')';
68 |
69 | return $res;
70 | };
71 |
72 | return $iifByteaSqlFx(
73 | $sqlLeft,
74 | $makeSqlFx($escapeNonUtf8Fx($sqlLeft), $escapeNonUtf8Fx($sqlRight)),
75 | $makeSqlFx('cast(' . $sqlLeft . ' as citext)', 'cast(' . $sqlRight . ' as citext)')
76 | );
77 | }
78 | );
79 | }
80 |
81 | #[\Override]
82 | protected function _renderConditionLikeOperator(bool $negated, string $sqlLeft, string $sqlRight): string
83 | {
84 | return ($negated ? 'not ' : '') . $this->_renderConditionConditionalCastToText($sqlLeft, $sqlRight, function ($sqlLeft, $sqlRight) {
85 | $sqlRightEscaped = 'regexp_replace(' . $sqlRight . ', '
86 | . $this->escapeStringLiteral('(\\\[\\\_%])|(\\\)') . ', '
87 | . $this->escapeStringLiteral('\1\2\2') . ', '
88 | . $this->escapeStringLiteral('g') . ')';
89 |
90 | return $sqlLeft . ' like ' . $sqlRightEscaped
91 | . ' escape ' . $this->escapeStringLiteral('\\');
92 | });
93 | }
94 |
95 | // needed for PostgreSQL v14 and lower
96 | #[\Override]
97 | protected function _renderConditionRegexpOperator(bool $negated, string $sqlLeft, string $sqlRight, bool $binary = false): string
98 | {
99 | return ($negated ? 'not ' : '') . $this->_renderConditionConditionalCastToText($sqlLeft, $sqlRight, static function ($sqlLeft, $sqlRight) {
100 | return $sqlLeft . ' ~ ' . $sqlRight;
101 | });
102 | }
103 |
104 | #[\Override]
105 | protected function _renderLimit(): ?string
106 | {
107 | if (!isset($this->args['limit'])) {
108 | return null;
109 | }
110 |
111 | return ' limit ' . (int) $this->args['limit']['cnt']
112 | . ' offset ' . (int) $this->args['limit']['shift'];
113 | }
114 |
115 | #[\Override]
116 | public function groupConcat($field, string $separator = ','): BaseExpression
117 | {
118 | return $this->expr('string_agg({}, [])', [$field, $separator]);
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/RawExpression.php:
--------------------------------------------------------------------------------
1 | connection->expr();
13 |
14 | // Closure rebind should not be needed
15 | // https://github.com/php/php-src/issues/14009
16 | return \Closure::bind(static fn () => $dummyExpression->escapeStringLiteral($value), null, parent::class)();
17 | }
18 |
19 | #[\Override]
20 | public function render(): array
21 | {
22 | return [$this->template, $this->args['custom']];
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Sqlite/Connection.php:
--------------------------------------------------------------------------------
1 | setMiddlewares([
25 | ...$configuration->getMiddlewares(),
26 | new EnableForeignKeys(),
27 | new PreserveAutoincrementOnRollbackMiddleware(),
28 | ...(
29 | version_compare(self::getDriverVersion(), '3.44') < 0
30 | ? [new CreateConcatFunctionMiddleware()]
31 | : []
32 | ),
33 | new CreateRegexpLikeFunctionMiddleware(),
34 | new CreateRegexpReplaceFunctionMiddleware(),
35 | ]);
36 |
37 | return $configuration;
38 | }
39 |
40 | /**
41 | * @internal
42 | */
43 | public static function getDriverVersion(): string
44 | {
45 | if ((self::$driverVersion ?? null) === null) {
46 | $dbalConnection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
47 | self::$driverVersion = $dbalConnection->getWrappedConnection()->getServerVersion(); // @phpstan-ignore method.deprecated, method.notFound
48 | }
49 |
50 | return self::$driverVersion;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Sqlite/CreateConcatFunctionMiddleware.php:
--------------------------------------------------------------------------------
1 | getNativeConnection();
29 | assert($nativeConnection instanceof \PDO);
30 |
31 | $nativeConnection->sqliteCreateFunction('concat', static function ($value, ...$values): string {
32 | $res = CreateRegexpLikeFunctionMiddleware::castScalarToString($value) ?? '';
33 | foreach ($values as $v) {
34 | $res .= CreateRegexpLikeFunctionMiddleware::castScalarToString($v);
35 | }
36 |
37 | return $res;
38 | }, -1, \PDO::SQLITE_DETERMINISTIC);
39 |
40 | return $connection;
41 | }
42 | };
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Sqlite/CreateRegexpLikeFunctionMiddleware.php:
--------------------------------------------------------------------------------
1 | getNativeConnection();
26 | assert($nativeConnection instanceof \PDO);
27 |
28 | $nativeConnection->sqliteCreateFunction('regexp_like', static function ($value, ?string $pattern, string $flags = ''): ?int {
29 | if ($value === null || $pattern === null) {
30 | return null;
31 | }
32 |
33 | $value = CreateRegexpLikeFunctionMiddleware::castScalarToString($value);
34 |
35 | if (str_contains($flags, '-u')) {
36 | $flags = str_replace('-u', '', $flags);
37 | $binary = true;
38 | } else {
39 | $binary = \PHP_VERSION_ID < 80200
40 | ? preg_match('~~u', $pattern) !== 1 // much faster in PHP 8.1 and lower
41 | || preg_match('~~u', $value) !== 1
42 | : !mb_check_encoding($pattern, 'UTF-8')
43 | || !mb_check_encoding($value, 'UTF-8');
44 | }
45 |
46 | $pregPattern = '~' . preg_replace('~(?getNativeConnection();
26 | assert($nativeConnection instanceof \PDO);
27 |
28 | $nativeConnection->sqliteCreateFunction('regexp_replace', static function ($value, ?string $pattern, ?string $replacement, string $flags = ''): ?string {
29 | if ($value === null || $pattern === null || $replacement === null) {
30 | return null;
31 | }
32 |
33 | $value = CreateRegexpLikeFunctionMiddleware::castScalarToString($value);
34 |
35 | if (str_contains($flags, '-u')) {
36 | $flags = str_replace('-u', '', $flags);
37 | $binary = true;
38 | } else {
39 | $binary = \PHP_VERSION_ID < 80200
40 | ? preg_match('~~u', $pattern) !== 1 // much faster in PHP 8.1 and lower
41 | || preg_match('~~u', $value) !== 1
42 | || preg_match('~~u', $replacement) !== 1
43 | : !mb_check_encoding($pattern, 'UTF-8')
44 | || !mb_check_encoding($value, 'UTF-8')
45 | || !mb_check_encoding($replacement, 'UTF-8');
46 | }
47 |
48 | $pregPattern = '~' . preg_replace('~(? $v) {
14 | if (($i % 2) === 1) {
15 | $parts[] = 'x\'' . bin2hex($v) . '\'';
16 | } elseif ($v !== '' || $i === 0) {
17 | $parts[] = '\'' . str_replace('\'', '\'\'', $v) . '\'';
18 | }
19 | }
20 |
21 | return $this->makeNaryTree($parts, 10, static function (array $parts) {
22 | if (count($parts) === 1) {
23 | return reset($parts);
24 | }
25 |
26 | return 'concat(' . implode(', ', $parts) . ')';
27 | });
28 | }
29 |
30 | #[\Override]
31 | protected function updateRenderBeforeExecute(array $render): array
32 | {
33 | [$sql, $params] = $render;
34 |
35 | $sql = preg_replace_callback(
36 | '~' . self::QUOTED_TOKEN_REGEX . '\K|:\w+~',
37 | static function ($matches) use ($params) {
38 | if ($matches[0] === '') {
39 | return '';
40 | }
41 |
42 | $sql = $matches[0];
43 | $value = $params[$sql];
44 |
45 | // emulate bind param support for float type
46 | // TODO open php-src feature request
47 | if (is_int($value)) {
48 | $sql = 'cast(' . $sql . ' as INTEGER)';
49 | } elseif (is_float($value)) {
50 | $sql = 'cast(' . $sql . ' as DOUBLE PRECISION)';
51 | }
52 |
53 | return $sql;
54 | },
55 | $sql
56 | );
57 |
58 | return parent::updateRenderBeforeExecute([$sql, $params]);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Sqlite/PlatformTrait.php:
--------------------------------------------------------------------------------
1 | */
17 | private $requireCommentHintTypes = [
18 | 'bigint',
19 | ];
20 |
21 | public function __construct()
22 | {
23 | $this->disableSchemaEmulation(); // @phpstan-ignore method.deprecated
24 | }
25 |
26 | #[\Override]
27 | public function getIdentifierQuoteCharacter(): string
28 | {
29 | return '`';
30 | }
31 |
32 | #[\Override]
33 | public function getAlterTableSQL(TableDiff $diff): array
34 | {
35 | // fix https://github.com/doctrine/dbal/pull/5501
36 | $diff = clone $diff;
37 | $diff->fromTable = clone $diff->fromTable; // @phpstan-ignore property.internal, property.internal
38 | foreach ($diff->fromTable->getForeignKeys() as $foreignKey) { // @phpstan-ignore property.internal
39 | \Closure::bind(static function () use ($foreignKey) {
40 | $foreignKey->_localColumnNames = $foreignKey->createIdentifierMap($foreignKey->getUnquotedLocalColumns());
41 | }, null, ForeignKeyConstraint::class)();
42 | }
43 |
44 | // fix no indexes, alter table drops and recreates the table newly, so indexes must be recreated as well
45 | // https://github.com/doctrine/dbal/pull/5486#issuecomment-1184957078
46 | $diff = clone $diff;
47 | $diff->addedIndexes = array_merge($diff->addedIndexes, $diff->fromTable->getIndexes()); // @phpstan-ignore property.internal, property.internal, property.internal
48 |
49 | return parent::getAlterTableSQL($diff);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Persistence/Sql/Sqlite/PreserveAutoincrementOnRollbackMiddleware.php:
--------------------------------------------------------------------------------
1 | _conn->executeQuery('PRAGMA foreign_keys')->fetchOne();
18 | if ($hadForeignKeysEnabled) {
19 | $this->_execSql('PRAGMA foreign_keys = 0'); // @phpstan-ignore method.internal
20 | }
21 |
22 | parent::alterTable($tableDiff);
23 |
24 | if ($hadForeignKeysEnabled) {
25 | $this->_execSql('PRAGMA foreign_keys = 1'); // @phpstan-ignore method.internal
26 |
27 | $rows = $this->_conn->executeQuery('PRAGMA foreign_key_check')->fetchAllAssociative();
28 | if (count($rows) > 0) {
29 | throw new DbalException('Foreign key constraints are violated');
30 | }
31 | }
32 | }
33 |
34 | // fix collations unescape for SQLiteSchemaManager::parseColumnCollationFromSQL() method
35 | // https://github.com/doctrine/dbal/issues/6129
36 |
37 | #[\Override]
38 | protected function _getPortableTableColumnList($table, $database, $tableColumns)
39 | {
40 | $res = parent::_getPortableTableColumnList($table, $database, $tableColumns);
41 | foreach ($res as $column) {
42 | if ($column->hasPlatformOption('collation')) {
43 | $column->setPlatformOption('collation', $this->unquoteTableIdentifier($column->getPlatformOption('collation')));
44 | }
45 | }
46 |
47 | return $res;
48 | }
49 |
50 | // fix quoted table name support for private SQLiteSchemaManager::getCreateTableSQL() method
51 | // https://github.com/doctrine/dbal/blob/3.3.7/src/Schema/SqliteSchemaManager.php#L539
52 | // TODO submit a PR with fixed SQLiteSchemaManager to DBAL
53 |
54 | private function unquoteTableIdentifier(string $tableName): string
55 | {
56 | return (new Identifier($tableName))->getName();
57 | }
58 |
59 | #[\Override]
60 | public function listTableDetails($name): Table
61 | {
62 | return parent::listTableDetails($this->unquoteTableIdentifier($name));
63 | }
64 |
65 | #[\Override]
66 | public function listTableIndexes($table): array
67 | {
68 | return parent::listTableIndexes($this->unquoteTableIdentifier($table));
69 | }
70 |
71 | #[\Override]
72 | public function listTableForeignKeys($table, $database = null): array
73 | {
74 | return parent::listTableForeignKeys($this->unquoteTableIdentifier($table), $database);
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/Reference/ContainsBase.php:
--------------------------------------------------------------------------------
1 | Array with UI flags like editable, visible and hidden. */
24 | public array $ui = [];
25 |
26 | /** @var string Required! We need table alias for internal use only. */
27 | protected $tableAlias = 'tbl';
28 |
29 | #[\Override]
30 | protected function init(): void
31 | {
32 | parent::init();
33 |
34 | if ($this->ourField === null) {
35 | $this->ourField = $this->link;
36 | }
37 |
38 | $ourModel = $this->getOurModel();
39 |
40 | $ourField = $this->getOurFieldName();
41 | if (!$ourModel->hasField($ourField)) {
42 | $ourModel->addField($ourField, [
43 | 'type' => $this->type,
44 | 'referenceLink' => $this->link,
45 | 'system' => $this->system,
46 | 'caption' => $this->caption, // it's reference models caption, but we can use it here for field too
47 | 'ui' => array_merge([
48 | 'visible' => false, // not visible in UI Table, Grid and Crud
49 | 'editable' => true, // but should be editable in UI Form
50 | ], $this->ui),
51 | ]);
52 | }
53 |
54 | // TODO https://github.com/atk4/data/issues/881
55 | // prevent unmanaged ContainsXxx data modification (/wo proper normalize, hooks, ...)
56 | $this->onHookToOurModel(Model::HOOK_NORMALIZE, function (Model $ourModel, Field $field, $value) {
57 | if (!$field->hasReference() || $field->shortName !== $this->getOurFieldName() || $value === null) {
58 | // this code relies on Field::$referenceLink set
59 | // also, allowing null value to be set will not fire any HOOK_BEFORE_DELETE/HOOK_AFTER_DELETE hook
60 | return;
61 | }
62 |
63 | foreach (array_slice(debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS), 1) as $frame) {
64 | if (($frame['class'] ?? null) === static::class) {
65 | return; // allow load/save from ContainsOne hooks
66 | }
67 | }
68 |
69 | throw new Exception('ContainsXxx does not support unmanaged data modification');
70 | });
71 | }
72 |
73 | #[\Override]
74 | protected function getDefaultPersistence(): Persistence
75 | {
76 | return new Persistence\Array_();
77 | }
78 |
79 | /**
80 | * @param array $data
81 | */
82 | protected function setTheirModelPersistenceSeedData(Model $theirModel, array $data): void
83 | {
84 | $persistence = Persistence\Array_::assertInstanceOf($theirModel->getPersistence());
85 | $tableName = $this->tableAlias;
86 | \Closure::bind(static function () use ($persistence, $tableName, $data) {
87 | $persistence->seedData = [$tableName => $data];
88 | $persistence->data = [];
89 | }, null, Persistence\Array_::class)();
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/Reference/ContainsMany.php:
--------------------------------------------------------------------------------
1 | assertOurModelOrEntity($ourModelOrEntity);
16 |
17 | $theirModel = $this->createTheirModel(array_merge($defaults, [
18 | 'containedInEntity' => $ourModelOrEntity->isEntity() ? $ourModelOrEntity : null,
19 | 'table' => $this->tableAlias,
20 | ]));
21 |
22 | $this->setTheirModelPersistenceSeedData(
23 | $theirModel,
24 | $ourModelOrEntity->isEntity() && $this->getOurFieldValue($ourModelOrEntity) !== null
25 | ? $this->getOurFieldValue($ourModelOrEntity)
26 | : []
27 | );
28 |
29 | foreach ([Model::HOOK_AFTER_SAVE, Model::HOOK_AFTER_DELETE] as $spot) {
30 | $this->onHookToTheirModel($theirModel, $spot, function (Model $theirEntity) {
31 | $ourEntity = $theirEntity->getModel()->containedInEntity;
32 | $this->assertOurModelOrEntity($ourEntity);
33 | $ourEntity->assertIsEntity();
34 |
35 | $persistence = Persistence\Array_::assertInstanceOf($theirEntity->getModel()->getPersistence());
36 | $rows = $persistence->getRawDataByTable($theirEntity->getModel(), $this->tableAlias); // @phpstan-ignore method.deprecated
37 | $ourEntity->save([$this->getOurFieldName() => $rows !== [] ? $rows : null]);
38 | });
39 | }
40 |
41 | return $theirModel;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Reference/ContainsOne.php:
--------------------------------------------------------------------------------
1 | assertOurModelOrEntity($ourModelOrEntity);
16 |
17 | $theirModel = $this->createTheirModel(array_merge($defaults, [
18 | 'containedInEntity' => $ourModelOrEntity->isEntity() ? $ourModelOrEntity : null,
19 | 'table' => $this->tableAlias,
20 | ]));
21 |
22 | $this->setTheirModelPersistenceSeedData(
23 | $theirModel,
24 | $ourModelOrEntity->isEntity() && $this->getOurFieldValue($ourModelOrEntity) !== null
25 | ? [1 => $this->getOurFieldValue($ourModelOrEntity)]
26 | : []
27 | );
28 |
29 | foreach ([Model::HOOK_AFTER_SAVE, Model::HOOK_AFTER_DELETE] as $spot) {
30 | $this->onHookToTheirModel($theirModel, $spot, function (Model $theirEntity) {
31 | $ourEntity = $theirEntity->getModel()->containedInEntity;
32 | $this->assertOurModelOrEntity($ourEntity);
33 | $ourEntity->assertIsEntity();
34 |
35 | $persistence = Persistence\Array_::assertInstanceOf($theirEntity->getModel()->getPersistence());
36 | $row = $persistence->getRawDataByTable($theirEntity->getModel(), $this->tableAlias); // @phpstan-ignore method.deprecated
37 | $row = $row ? array_shift($row) : null; // get first and only one record from array persistence
38 | $ourEntity->save([$this->getOurFieldName() => $row]);
39 | });
40 | }
41 |
42 | if ($ourModelOrEntity->isEntity()) {
43 | $theirModelOrig = $theirModel;
44 | $theirModel = $theirModel->tryLoadOne();
45 |
46 | if ($theirModel === null) {
47 | $theirModel = $theirModelOrig->createEntity();
48 | }
49 | }
50 |
51 | return $theirModel;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/Reference/WeakAnalysingBoxedArray.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class WeakAnalysingBoxedArray
13 | {
14 | /** @var T */
15 | private array $value;
16 |
17 | /**
18 | * @param T $value
19 | */
20 | public function __construct(array $value)
21 | {
22 | $this->value = $value;
23 | }
24 |
25 | /**
26 | * @return T
27 | */
28 | public function get(): array
29 | {
30 | return $this->value;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Schema/TestSqlPersistence.php:
--------------------------------------------------------------------------------
1 | _connection ?? null) === null) {
25 | $this->_connection = Persistence::connect($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'])->_connection; // @phpstan-ignore property.notFound
26 |
27 | if ($this->getDatabasePlatform() instanceof MySQLPlatform) {
28 | $this->getConnection()->expr(
29 | 'SET SESSION auto_increment_increment = 1, SESSION auto_increment_offset = 1'
30 | )->executeStatement();
31 | }
32 |
33 | $this->getConnection()->getConnection()->getConfiguration()->setSQLLogger( // @phpstan-ignore method.deprecated
34 | // @phpstan-ignore class.implementsDeprecatedInterface (TODO PHP CS Fixer should allow comment on the same line)
35 | new class implements SQLLogger {
36 | #[\Override]
37 | public function startQuery($sql, ?array $params = null, ?array $types = null): void
38 | {
39 | // log transaction savepoint operations only once
40 | // https://github.com/doctrine/dbal/blob/3.6.7/src/Connection.php#L1365
41 | if (preg_match('~^(?:SAVEPOINT|RELEASE SAVEPOINT|ROLLBACK TO SAVEPOINT|SAVE TRANSACTION|ROLLBACK TRANSACTION) DOCTRINE2_SAVEPOINT_\d+;?$~', $sql)) {
42 | return;
43 | }
44 |
45 | // fix https://github.com/doctrine/dbal/issues/5525
46 | if ($params !== null && $params !== [] && array_is_list($params)) {
47 | $params = array_combine(range(1, count($params)), $params);
48 | }
49 |
50 | $test = TestCase::getTestFromBacktrace();
51 | \Closure::bind(static fn () => $test->logQuery($sql, $params ?? [], $types ?? []), null, TestCase::class)(); // @phpstan-ignore argument.type
52 | }
53 |
54 | #[\Override]
55 | public function stopQuery(): void {}
56 | }
57 | );
58 | }
59 | }, $this, Persistence\Sql::class)();
60 |
61 | return parent::getConnection();
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/Type/LocalObjectHandle.php:
--------------------------------------------------------------------------------
1 | */
15 | private \WeakReference $weakValue;
16 |
17 | /**
18 | * @var \Closure($this): void
19 | */
20 | private \Closure $destructFx;
21 |
22 | /**
23 | * @param \Closure($this): void $destructFx
24 | */
25 | public function __construct(int $localUid, object $value, \Closure $destructFx)
26 | {
27 | $this->localUid = $localUid;
28 | $this->weakValue = \WeakReference::create($value);
29 | $this->destructFx = $destructFx;
30 | }
31 |
32 | public function __destruct()
33 | {
34 | ($this->destructFx)($this);
35 | }
36 |
37 | public function getLocalUid(): int
38 | {
39 | return $this->localUid;
40 | }
41 |
42 | public function getValue(): ?object
43 | {
44 | return $this->weakValue->get();
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Type/LocalObjectType.php:
--------------------------------------------------------------------------------
1 | */
27 | private \WeakMap $handles;
28 | /** @var array> */
29 | private array $handlesIndex;
30 |
31 | private function __clone()
32 | {
33 | // prevent cloning
34 | }
35 |
36 | protected function init(): void
37 | {
38 | $this->instanceUid = hash('sha256', microtime(true) . random_bytes(64));
39 | $this->localUidCounter = 0;
40 | $this->handles = new \WeakMap();
41 | $this->handlesIndex = [];
42 | }
43 |
44 | #[\Override]
45 | public function getName(): string
46 | {
47 | return Types::LOCAL_OBJECT;
48 | }
49 |
50 | #[\Override]
51 | public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
52 | {
53 | return DbalTypes\Type::getType(DbalTypes\Types::STRING)->getSQLDeclaration($fieldDeclaration, $platform);
54 | }
55 |
56 | #[\Override]
57 | public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
58 | {
59 | if ($value === null) {
60 | return null;
61 | }
62 |
63 | if ($this->instanceUid === null) {
64 | $this->init();
65 | }
66 |
67 | $handle = $this->handles->offsetExists($value)
68 | ? $this->handles->offsetGet($value)
69 | : null;
70 |
71 | if ($handle === null) {
72 | $handle = new LocalObjectHandle(++$this->localUidCounter, $value, function (LocalObjectHandle $handle): void {
73 | unset($this->handlesIndex[$handle->getLocalUid()]);
74 | });
75 | $this->handles->offsetSet($value, $handle);
76 | $this->handlesIndex[$handle->getLocalUid()] = \WeakReference::create($handle);
77 | }
78 |
79 | $className = get_debug_type($value);
80 | if (strlen($className) > 160) { // keep result below 255 bytes
81 | $className = mb_strcut($className, 0, 80)
82 | . '...'
83 | . mb_strcut(substr($className, strlen(mb_strcut($className, 0, 80))), -80);
84 | }
85 |
86 | return $className . '-' . $this->instanceUid . '-' . $handle->getLocalUid();
87 | }
88 |
89 | #[\Override]
90 | public function convertToPHPValue($value, AbstractPlatform $platform): ?object
91 | {
92 | if ($value === null || trim($value) === '') {
93 | return null;
94 | }
95 |
96 | $valueExploded = explode('-', $value, 3);
97 | if (count($valueExploded) !== 3
98 | || $valueExploded[1] !== $this->instanceUid
99 | || $valueExploded[2] !== (string) (int) $valueExploded[2]
100 | ) {
101 | throw new Exception('Local object does not match the DBAL type instance');
102 | }
103 | $handle = $this->handlesIndex[(int) $valueExploded[2]] ?? null;
104 | if ($handle !== null) {
105 | $handle = $handle->get();
106 | }
107 | $res = $handle !== null ? $handle->getValue() : null;
108 | if ($res === null) {
109 | throw new Exception('Local object does no longer exist');
110 | }
111 |
112 | return $res;
113 | }
114 |
115 | #[\Override]
116 | public function requiresSQLCommentHint(AbstractPlatform $platform): bool
117 | {
118 | return true;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/Type/MoneyType.php:
--------------------------------------------------------------------------------
1 | getSQLDeclaration($fieldDeclaration, $platform);
22 | }
23 |
24 | #[\Override]
25 | public function convertToDatabaseValue($value, AbstractPlatform $platform): ?float
26 | {
27 | if ($value === null || trim((string) $value) === '') {
28 | return null;
29 | }
30 |
31 | return round((float) $value, 4);
32 | }
33 |
34 | #[\Override]
35 | public function convertToPHPValue($value, AbstractPlatform $platform): ?float
36 | {
37 | return $this->convertToDatabaseValue($value, $platform);
38 | }
39 |
40 | #[\Override]
41 | public function requiresSQLCommentHint(AbstractPlatform $platform): bool
42 | {
43 | return true;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Type/Types.php:
--------------------------------------------------------------------------------
1 | getParams()['depth'] ?? null;
17 |
18 | $this->addMoreInfo('depth', $prefix . ($innerDepth === null ? '' : ' -> ' . $innerDepth));
19 |
20 | return $this;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/ValidationException.php:
--------------------------------------------------------------------------------
1 | */
10 | public array $errors;
11 |
12 | /**
13 | * @param array $errors
14 | *
15 | * @return \Exception
16 | */
17 | public function __construct(array $errors, ?Model $model = null)
18 | {
19 | if (count($errors) === 0) {
20 | throw new Exception('At least one error must be given');
21 | }
22 |
23 | $this->errors = $errors;
24 |
25 | if (count($errors) === 1) {
26 | parent::__construct(reset($errors));
27 |
28 | $this->addMoreInfo('field', array_key_first($errors));
29 | } else {
30 | parent::__construct('Multiple validation errors');
31 |
32 | $this->addMoreInfo('errors', $errors);
33 | }
34 |
35 | $this->addMoreInfo('model', $model);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tests/ContainsMany/Discount.php:
--------------------------------------------------------------------------------
1 | addField($this->fieldName()->percent, ['type' => 'integer', 'required' => true]);
21 | $this->addField($this->fieldName()->valid_till, ['type' => 'datetime']);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tests/ContainsMany/Invoice.php:
--------------------------------------------------------------------------------
1 | titleField = $this->fieldName()->ref_no;
26 |
27 | $this->addField($this->fieldName()->ref_no, ['required' => true]);
28 | $this->addField($this->fieldName()->amount, ['type' => 'atk4_money']);
29 |
30 | // will contain many Lines
31 | $this->containsMany($this->fieldName()->lines, ['model' => [Line::class], 'caption' => 'My Invoice Lines']);
32 |
33 | // total_gross - calculated by php callback not by SQL expression
34 | $this->addCalculatedField($this->fieldName()->total_gross, ['expr' => static function (self $m) {
35 | $total = 0;
36 | foreach ($m->lines as $line) {
37 | $total += $line->total_gross;
38 | }
39 |
40 | return $total;
41 | }, 'type' => 'float']);
42 |
43 | // discounts_total_sum - calculated by php callback not by SQL expression
44 | $this->addCalculatedField($this->fieldName()->discounts_total_sum, ['expr' => static function (self $m) {
45 | $total = 0;
46 | foreach ($m->lines as $line) {
47 | $total += $line->total_gross * $line->discounts_percent / 100;
48 | }
49 |
50 | return $total;
51 | }, 'type' => 'float']);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/tests/ContainsMany/Line.php:
--------------------------------------------------------------------------------
1 | hasOne($this->fieldName()->vat_rate_id, ['model' => [VatRate::class]]);
26 |
27 | $this->addField($this->fieldName()->price, ['type' => 'atk4_money', 'required' => true]);
28 | $this->addField($this->fieldName()->qty, ['type' => 'float', 'required' => true]);
29 | $this->addField($this->fieldName()->add_date, ['type' => 'datetime']);
30 |
31 | $this->addExpression($this->fieldName()->total_gross, ['expr' => static function (self $m) {
32 | return $m->price * $m->qty * (1 + $m->vat_rate_id->rate / 100);
33 | }, 'type' => 'float']);
34 |
35 | // each line can have multiple discounts and calculate total of these discounts
36 | $this->containsMany($this->fieldName()->discounts, ['model' => [Discount::class]]);
37 |
38 | $this->addCalculatedField($this->fieldName()->discounts_percent, ['expr' => static function (self $m) {
39 | $total = 0;
40 | foreach ($m->discounts as $d) {
41 | $total += $d->percent;
42 | }
43 |
44 | return $total;
45 | }, 'type' => 'float']);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/tests/ContainsMany/VatRate.php:
--------------------------------------------------------------------------------
1 | addField($this->fieldName()->name);
23 | $this->addField($this->fieldName()->rate, ['type' => 'integer']);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/tests/ContainsOne/Address.php:
--------------------------------------------------------------------------------
1 | $tags @Atk4\Field()
14 | * @property DoorCode $door_code @Atk4\RefOne()
15 | */
16 | class Address extends Model
17 | {
18 | #[\Override]
19 | protected function init(): void
20 | {
21 | parent::init();
22 |
23 | $this->hasOne($this->fieldName()->country_id, ['model' => [Country::class]]);
24 |
25 | $this->addField($this->fieldName()->address);
26 | $this->addField($this->fieldName()->built_date, ['type' => 'datetime']);
27 | $this->addField($this->fieldName()->tags, ['type' => 'json', 'default' => []]);
28 |
29 | $this->containsOne($this->fieldName()->door_code, ['model' => [DoorCode::class], 'caption' => 'Secret Code']);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tests/ContainsOne/Country.php:
--------------------------------------------------------------------------------
1 | addField($this->fieldName()->name);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tests/ContainsOne/DoorCode.php:
--------------------------------------------------------------------------------
1 | addField($this->fieldName()->code);
21 | $this->addField($this->fieldName()->valid_till, ['type' => 'datetime']);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tests/ContainsOne/Invoice.php:
--------------------------------------------------------------------------------
1 | titleField = $this->fieldName()->ref_no;
23 |
24 | $this->addField($this->fieldName()->ref_no, ['required' => true]);
25 |
26 | $this->containsOne($this->fieldName()->addr, ['model' => [Address::class]]);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tests/Field/EmailFieldTest.php:
--------------------------------------------------------------------------------
1 | addField('email', [EmailField::class]);
18 | $entity = $m->createEntity();
19 |
20 | self::assertNull($entity->get('email'));
21 |
22 | // normal value
23 | $entity->set('email', 'foo@example.com');
24 | self::assertSame('foo@example.com', $entity->get('email'));
25 |
26 | // null value
27 | $entity->set('email', null);
28 | self::assertNull($entity->get('email'));
29 |
30 | // padding, spacing etc removed
31 | $entity->set('email', " \t " . 'foo@example.com ' . " \n ");
32 | self::assertSame('foo@example.com', $entity->get('email'));
33 |
34 | // no domain - go to hell :)
35 | $this->expectException(ValidationException::class);
36 | $this->expectExceptionMessage('does not have domain');
37 | $entity->set('email', 'xx');
38 | }
39 |
40 | public function testEmailValidateDns(): void
41 | {
42 | $m = new Model();
43 | $m->addField('email', [EmailField::class, 'dnsCheck' => true]);
44 | $entity = $m->createEntity();
45 |
46 | $entity->set('email', ' foo@gmail.com');
47 | self::assertSame('foo@gmail.com', $entity->get('email'));
48 |
49 | $entity->set('email', ' foo@mail.co.uk');
50 | self::assertSame('foo@mail.co.uk', $entity->get('email'));
51 |
52 | $entity->set('email', 'test@háčkyčárky.cz'); // official IDN test domain
53 | self::assertSame('test@háčkyčárky.cz', $entity->get('email'));
54 |
55 | $this->expectException(ValidationException::class);
56 | $this->expectExceptionMessage('domain does not exist');
57 | $entity->set('email', 'test@háčkyčárky2.cz');
58 | }
59 |
60 | public function testEmailWithName(): void
61 | {
62 | $m = new Model();
63 | $m->addField('email', [EmailField::class]);
64 | $m->addField('email_name', [EmailField::class, 'allowName' => true]);
65 | $entity = $m->createEntity();
66 |
67 | $entity->set('email_name', 'Žlutý Kůň ');
68 | self::assertSame('Žlutý Kůň ', $entity->get('email_name'));
69 |
70 | $this->expectException(ValidationException::class);
71 | $this->expectExceptionMessage('format is invalid');
72 | $entity->set('email', 'Romans ');
73 | }
74 |
75 | public function testEmailMultipleException(): void
76 | {
77 | $m = new Model();
78 | $m->addField('email', [EmailField::class]);
79 | $entity = $m->createEntity();
80 |
81 | $this->expectException(ValidationException::class);
82 | $this->expectExceptionMessage('format is invalid');
83 | $entity->set('email', 'foo@exampe.com, bar@example.com');
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/tests/Field/PasswordFieldTest.php:
--------------------------------------------------------------------------------
1 | addField('p', [PasswordField::class]);
18 | $field = PasswordField::assertInstanceOf($m->getField('p'));
19 | $entity = $m->createEntity();
20 |
21 | self::assertNull($entity->get('p'));
22 |
23 | $field->setPassword($entity, 'myPassword');
24 | self::assertIsString($entity->get('p'));
25 | self::assertNotSame('myPassword', $entity->get('p'));
26 | self::assertFalse($field->verifyPassword($entity, 'badPassword'));
27 | self::assertTrue($field->verifyPassword($entity, 'myPassword'));
28 |
29 | // password is always normalized using string type
30 | self::assertTrue($field->verifyPassword($entity, 'myPassword '));
31 | self::assertFalse($field->verifyPassword($entity, 'myPassword .'));
32 |
33 | $field->set($entity, null);
34 | self::assertNull($entity->get('p'));
35 | }
36 |
37 | public function testInvalidPasswordAlreadyHashed(): void
38 | {
39 | $field = new PasswordField();
40 | $hash = $field->hashPassword('myPassword');
41 |
42 | $this->expectException(Exception::class);
43 | $field->hashPassword($hash);
44 | }
45 |
46 | public function testInvalidPasswordTooShortDefault(): void
47 | {
48 | $field = new PasswordField();
49 | $pwd = 'žlutý__';
50 | self::assertTrue(mb_strlen($pwd) < $field->minLength);
51 | self::assertTrue(strlen($pwd) >= $field->minLength);
52 |
53 | $this->expectException(Exception::class);
54 | $field->hashPassword($pwd);
55 | }
56 |
57 | public function testInvalidPasswordTooShortCustomized(): void
58 | {
59 | $field = new PasswordField();
60 | $pwd = 'myPassword';
61 | self::assertFalse($field->hashPasswordIsHashed($pwd));
62 | $hash = $field->hashPassword($pwd);
63 | self::assertTrue($field->hashPasswordIsHashed($hash));
64 |
65 | $field->minLength = 50;
66 |
67 | // minLength is ignored for verify
68 | self::assertTrue($field->hashPasswordVerify($hash, $pwd . ' '));
69 |
70 | // but checked when password is being hashed
71 | $this->expectException(Exception::class);
72 | $field->hashPassword(str_repeat('x', 49));
73 | }
74 |
75 | public function testInvalidPasswordCntrlChar(): void
76 | {
77 | $field = new PasswordField();
78 | $pwd = 'myPassword' . "\t" . 'x';
79 | $hash = $field->hashPassword($pwd);
80 | self::assertTrue($field->hashPasswordIsHashed($hash));
81 | self::assertTrue($field->hashPasswordVerify($hash, str_replace("\t", ' ', $pwd)));
82 |
83 | $this->expectException(Exception::class);
84 | $field->hashPassword('myPassword' . "\x07" . 'x');
85 | }
86 |
87 | public function testSetUnhashedException(): void
88 | {
89 | $m = new Model();
90 | $m->addField('p', [PasswordField::class]);
91 | $field = PasswordField::assertInstanceOf($m->getField('p'));
92 | $entity = $m->createEntity();
93 |
94 | $this->expectException(Exception::class);
95 | $field->set($entity, 'myPassword');
96 | }
97 |
98 | public function testEmptyCompareException(): void
99 | {
100 | $m = new Model();
101 | $m->addField('p', [PasswordField::class]);
102 | $field = PasswordField::assertInstanceOf($m->getField('p'));
103 | $entity = $m->createEntity();
104 |
105 | $this->expectException(Exception::class);
106 | $field->verifyPassword($entity, 'myPassword');
107 | }
108 |
109 | public function testGeneratePassword(): void
110 | {
111 | $field = new PasswordField();
112 |
113 | $pwd = $field->generatePassword();
114 | self::assertSame(8, strlen($pwd));
115 |
116 | $pwd = $field->generatePassword(50);
117 | self::assertSame(50, strlen($pwd));
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/tests/FolderTest.php:
--------------------------------------------------------------------------------
1 | addField('name');
20 |
21 | $this->hasMany('SubFolder', ['model' => [self::class], 'theirField' => 'parent_id'])
22 | ->addField('count', ['aggregate' => 'count', 'field' => $this->getPersistence()->expr($this, '*')]); // @phpstan-ignore method.notFound
23 |
24 | $this->hasOne('parent_id', ['model' => [self::class]])
25 | ->addTitle();
26 |
27 | $this->addField('is_deleted', ['type' => 'boolean']);
28 | $this->addCondition('is_deleted', false);
29 | }
30 | }
31 |
32 | class FolderTest extends TestCase
33 | {
34 | public function testRate(): void
35 | {
36 | $this->setDb([
37 | 'folder' => [
38 | ['parent_id' => 1, 'is_deleted' => false, 'name' => 'Desktop'],
39 | ['parent_id' => 1, 'is_deleted' => false, 'name' => 'My Documents'],
40 | ['parent_id' => 1, 'is_deleted' => false, 'name' => 'My Videos'],
41 | ['parent_id' => 1, 'is_deleted' => false, 'name' => 'My Projects'],
42 | ['parent_id' => 4, 'is_deleted' => false, 'name' => 'Agile Data'],
43 | ['parent_id' => 4, 'is_deleted' => false, 'name' => 'DSQL'],
44 | ['parent_id' => 4, 'is_deleted' => false, 'name' => 'Agile Toolkit'],
45 | ['parent_id' => 4, 'is_deleted' => true, 'name' => 'test-project'],
46 | ],
47 | ]);
48 |
49 | $f = new Folder($this->db);
50 | $this->createMigrator()->createForeignKey($f->getReference('SubFolder'));
51 | $f = $f->load(4);
52 |
53 | self::assertSame([
54 | 'id' => 4,
55 | 'name' => 'My Projects',
56 | 'count' => '3',
57 | 'parent_id' => 1,
58 | 'parent' => 'Desktop',
59 | 'is_deleted' => false,
60 | ], $f->get());
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/tests/Model/Client.php:
--------------------------------------------------------------------------------
1 | addField('order', ['type' => 'integer', 'default' => 10]);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tests/Model/Female.php:
--------------------------------------------------------------------------------
1 | addCondition('gender', 'F');
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/tests/Model/Invoice.php:
--------------------------------------------------------------------------------
1 | hasOne('client_id', ['model' => [Client::class]]);
19 | $this->addField('name');
20 | $this->addField('amount', ['type' => 'atk4_money']);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/tests/Model/Male.php:
--------------------------------------------------------------------------------
1 | addCondition('gender', 'M');
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/tests/Model/Person.php:
--------------------------------------------------------------------------------
1 | addField('name');
19 | $this->addField('surname');
20 | $this->addField('gender', ['enum' => ['M', 'F']]);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/tests/Model/Smbo/Account.php:
--------------------------------------------------------------------------------
1 | addField('name');
19 |
20 | $this->hasMany('Payment', ['model' => [Payment::class]])
21 | ->addField('balance', ['aggregate' => 'sum', 'field' => 'amount', 'type' => 'atk4_money']);
22 | }
23 |
24 | /**
25 | * Create and return a transfer model.
26 | */
27 | public function transfer(self $a, float $amount): Transfer
28 | {
29 | $t = new Transfer($this->getModel()->getPersistence(), ['detached' => true]);
30 | $t = $t->createEntity();
31 | $t->set('account_id', $this->getId());
32 | $t->set('destination_account_id', $a->getId());
33 | $t->set('amount', -$amount);
34 |
35 | return $t;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tests/Model/Smbo/Document.php:
--------------------------------------------------------------------------------
1 | addField('doc_type', ['enum' => ['invoice', 'payment']]);
19 | $this->addField('amount', ['type' => 'atk4_money']);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tests/Model/Smbo/Payment.php:
--------------------------------------------------------------------------------
1 | addCondition('doc_type', 'payment');
20 |
21 | $this->jPayment = $this->join('payment.document_id', ['allowDangerousForeignTableUpdate' => true]);
22 |
23 | $this->jPayment->addField('cheque_no');
24 | $this->jPayment->hasOne('account_id', ['model' => [Account::class]]);
25 | $this->jPayment->addField('misc_payment', ['type' => 'boolean']);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/Model/Smbo/Transfer.php:
--------------------------------------------------------------------------------
1 | jPayment->hasOne('transfer_document_id', ['model' => [self::class]]);
20 |
21 | // only used to create / destroy transfer legs
22 | if (!$this->detached) {
23 | $this->addCondition('transfer_document_id', '!=', null);
24 | }
25 |
26 | $this->addField('destination_account_id', ['type' => 'bigint', 'neverPersist' => true]);
27 |
28 | $this->onHookShort(self::HOOK_BEFORE_SAVE, function () {
29 | // only for new records and when destination_account_id is set
30 | if ($this->get('destination_account_id') && !$this->getId()) {
31 | // in this section we test if "clone" works ok
32 |
33 | $m2 = clone $this;
34 | $this->otherLegCreation = $m2;
35 | $m2->set('account_id', $m2->get('destination_account_id'));
36 | $m2->set('amount', -$m2->get('amount'));
37 |
38 | $m2->_unset('destination_account_id');
39 |
40 | $this->set('transfer_document_id', $m2->save()->getId());
41 | }
42 | });
43 |
44 | $this->onHookShort(self::HOOK_AFTER_SAVE, function () {
45 | if ($this->otherLegCreation) {
46 | $this->otherLegCreation->set('transfer_document_id', $this->getId())->save();
47 | }
48 | $this->otherLegCreation = null;
49 | });
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/tests/Model/User.php:
--------------------------------------------------------------------------------
1 | addField('name');
17 | $this->addField('surname');
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tests/ModelWithCteTest.php:
--------------------------------------------------------------------------------
1 | setDb([
18 | 'user' => [
19 | 10 => ['id' => 10, 'name' => 'John', 'salary' => 2500],
20 | 20 => ['id' => 20, 'name' => 'Peter', 'salary' => 4000],
21 | ],
22 | 'invoice' => [
23 | 1 => ['id' => 1, 'net' => 500, 'user_id' => 10],
24 | ['id' => 2, 'net' => 200, 'user_id' => 20],
25 | ['id' => 3, 'net' => 100, 'user_id' => 20],
26 | ['id' => 4, 'net' => 400, 'user_id' => 20],
27 | ],
28 | ]);
29 |
30 | $mUser = new Model($this->db, ['table' => 'user']);
31 | $mUser->addField('name');
32 | $mUser->addField('salary', ['type' => 'integer']);
33 |
34 | $mInvoice = new Model($this->db, ['table' => 'invoice']);
35 | $mInvoice->addField('net', ['type' => 'integer']);
36 | $mInvoice->hasOne('user_id', ['model' => $mUser]);
37 | $mInvoice->addCondition('net', '>', 100);
38 |
39 | $m = clone $mUser;
40 | $m->addCteModel('i', $mInvoice);
41 | $jInvoice = $m->join('i.user_id');
42 | $jInvoice->addField('invoiced', ['type' => 'integer', 'actual' => 'net']); // add field from joined CTE
43 |
44 | $this->assertSameSql(
45 | 'with `i` as (select `id`, `net`, `user_id` from `invoice` where `net` > :a)' . "\n"
46 | . 'select `user`.`id`, `user`.`name`, `user`.`salary`, `_i`.`net` `invoiced` from `user` inner join `i` `_i` on `_i`.`user_id` = `user`.`id`',
47 | $m->action('select')->render()[0]
48 | );
49 |
50 | if ($this->getDatabasePlatform() instanceof MySQLPlatform
51 | && !MysqlConnection::isServerMariaDb($this->getConnection())
52 | && MysqlConnection::getServerMinorVersion($this->getConnection()) < 600
53 | ) {
54 | self::markTestIncomplete('MySQL 5.x does not support WITH clause');
55 | }
56 |
57 | self::assertSameExportUnordered([
58 | ['id' => 10, 'name' => 'John', 'salary' => 2500, 'invoiced' => 500],
59 | ['id' => 20, 'name' => 'Peter', 'salary' => 4000, 'invoiced' => 200],
60 | ['id' => 20, 'name' => 'Peter', 'salary' => 4000, 'invoiced' => 400],
61 | ], $m->export());
62 | }
63 |
64 | public function testUniqueNameException1(): void
65 | {
66 | $m1 = new Model(null, ['table' => 't']);
67 | $m2 = new Model();
68 |
69 | $this->expectException(Exception::class);
70 | $this->expectExceptionMessage('CTE model with given name already exist');
71 | $m1->addCteModel('t', $m2);
72 | }
73 |
74 | public function testUniqueNameException2(): void
75 | {
76 | $m1 = new Model(null, ['tableAlias' => 't']);
77 | $m2 = new Model();
78 |
79 | $this->expectException(Exception::class);
80 | $this->expectExceptionMessage('CTE model with given name already exist');
81 | $m1->addCteModel('t', $m2);
82 | }
83 |
84 | public function testUniqueNameException3(): void
85 | {
86 | $m1 = new Model();
87 | $m2 = new Model();
88 | $m1->addCteModel('t', $m2);
89 |
90 | $this->expectException(Exception::class);
91 | $this->expectExceptionMessage('CTE model with given name already exist');
92 | $m1->addCteModel('t', $m2);
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/tests/ModelWithoutIdTest.php:
--------------------------------------------------------------------------------
1 | setDb([
23 | 'user' => [
24 | 1 => ['id' => 1, 'name' => 'John', 'gender' => 'M'],
25 | ['id' => 2, 'name' => 'Sue', 'gender' => 'F'],
26 | ],
27 | ]);
28 |
29 | $this->m = new Model($this->db, ['table' => 'user', 'idField' => false]);
30 | $this->m->addField('name');
31 | $this->m->addField('gender');
32 | }
33 |
34 | /**
35 | * Basic operation should work just fine on model without ID.
36 | */
37 | public function testBasic(): void
38 | {
39 | $this->m->setOrder('name', 'asc');
40 | $m = $this->m->loadAny();
41 | self::assertSame('John', $m->get('name'));
42 |
43 | $this->m->order = [];
44 | $this->m->setOrder('name', 'desc');
45 | $m = $this->m->loadAny();
46 | self::assertSame('Sue', $m->get('name'));
47 |
48 | $names = [];
49 | foreach ($this->m as $row) {
50 | $names[] = $row->get('name');
51 | }
52 | self::assertSame(['Sue', 'John'], $names);
53 | }
54 |
55 | public function testGetIdFieldException(): void
56 | {
57 | $this->expectException(Exception::class);
58 | $this->expectExceptionMessage('ID field is not defined');
59 | $this->m->getIdField();
60 | }
61 |
62 | public function testGetIdException(): void
63 | {
64 | $m = $this->m->loadAny();
65 |
66 | $this->expectException(Exception::class);
67 | $this->expectExceptionMessage('ID field is not defined');
68 | $m->getId();
69 | }
70 |
71 | public function testSetIdException(): void
72 | {
73 | $m = $this->m->createEntity();
74 |
75 | $this->expectException(Exception::class);
76 | $this->expectExceptionMessage('ID field is not defined');
77 | $m->setId(1);
78 | }
79 |
80 | public function testFail1(): void
81 | {
82 | $this->expectException(Exception::class);
83 | $this->expectExceptionMessage('Unable to load by "id" when Model->idField is not defined');
84 | $this->m->load(1);
85 | }
86 |
87 | /**
88 | * Inserting into model without ID should be OK.
89 | */
90 | public function testInsert(): void
91 | {
92 | if ($this->getDatabasePlatform() instanceof PostgreSQLPlatform) {
93 | self::markTestIncomplete('PostgreSQL requires PK specified in SQL to use autoincrement');
94 | }
95 |
96 | $this->m->insert(['name' => 'Joe']);
97 | self::assertSame(3, $this->m->executeCountQuery());
98 | }
99 |
100 | /**
101 | * Since no ID is set, a new record will be created if saving is attempted.
102 | */
103 | public function testSave1(): void
104 | {
105 | if ($this->getDatabasePlatform() instanceof PostgreSQLPlatform) {
106 | self::markTestIncomplete('PostgreSQL requires PK specified in SQL to use autoincrement');
107 | }
108 |
109 | $m = $this->m->loadAny();
110 | $m->saveAndUnload();
111 |
112 | self::assertSame(3, $this->m->executeCountQuery());
113 | }
114 |
115 | /**
116 | * Calling save will always create new record.
117 | */
118 | public function testSave2(): void
119 | {
120 | if ($this->getDatabasePlatform() instanceof PostgreSQLPlatform) {
121 | self::markTestIncomplete('PostgreSQL requires PK specified in SQL to use autoincrement');
122 | }
123 |
124 | $m = $this->m->loadAny();
125 | $m->save();
126 |
127 | self::assertSame(3, $this->m->executeCountQuery());
128 | }
129 |
130 | public function testLoadBy(): void
131 | {
132 | $m = $this->m->loadBy('name', 'Sue');
133 | self::assertSame('Sue', $m->get('name'));
134 | }
135 |
136 | public function testLoadCondition(): void
137 | {
138 | $this->m->addCondition('name', 'Sue');
139 | $m = $this->m->loadAny();
140 | self::assertSame('Sue', $m->get('name'));
141 | }
142 |
143 | public function testFailDelete1(): void
144 | {
145 | $this->expectException(Exception::class);
146 | $this->expectExceptionMessage('Unable to load by "id" when Model->idField is not defined');
147 | $this->m->delete(4);
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/tests/Persistence/GenericPlatformTest.php:
--------------------------------------------------------------------------------
1 | getName()); // @phpstan-ignore method.deprecated
18 | }
19 |
20 | public function testInitializeDoctrineTypeMappings(): void
21 | {
22 | $genericPlatform = new GenericPlatform();
23 | self::assertFalse($genericPlatform->hasDoctrineTypeMappingFor('foo'));
24 | }
25 |
26 | /**
27 | * @dataProvider provideNotSupportedExceptionCases
28 | *
29 | * @param list $args
30 | */
31 | #[DataProvider('provideNotSupportedExceptionCases')]
32 | public function testNotSupportedException(string $methodName, array $args): void
33 | {
34 | $genericPlatform = new GenericPlatform();
35 |
36 | $this->expectException(DbalException::class);
37 | $this->expectExceptionMessage('Operation \'SQL\' is not supported by platform.');
38 | \Closure::bind(static fn () => $genericPlatform->{$methodName}(...$args), null, GenericPlatform::class)();
39 | }
40 |
41 | /**
42 | * @return iterable>
43 | */
44 | public static function provideNotSupportedExceptionCases(): iterable
45 | {
46 | yield ['_getCommonIntegerTypeDeclarationSQL', [[]]];
47 | yield ['getBigIntTypeDeclarationSQL', [[]]];
48 | yield ['getBlobTypeDeclarationSQL', [[]]];
49 | yield ['getBooleanTypeDeclarationSQL', [[]]];
50 | yield ['getClobTypeDeclarationSQL', [[]]];
51 | yield ['getIntegerTypeDeclarationSQL', [[]]];
52 | yield ['getSmallIntTypeDeclarationSQL', [[]]];
53 | yield ['getCurrentDatabaseExpression', []];
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/tests/ReadOnlyModeTest.php:
--------------------------------------------------------------------------------
1 | setDb([
22 | 'user' => [
23 | 1 => ['id' => 1, 'name' => 'John', 'gender' => 'M'],
24 | ['id' => 2, 'name' => 'Sue', 'gender' => 'F'],
25 | ],
26 | ]);
27 |
28 | $this->m = new Model($this->db, ['table' => 'user', 'readOnly' => true]);
29 | $this->m->addField('name');
30 | $this->m->addField('gender');
31 | }
32 |
33 | /**
34 | * Basic operation should work just fine on model without ID.
35 | */
36 | public function testBasic(): void
37 | {
38 | $this->m->setOrder('name', 'asc');
39 | $m = $this->m->loadAny();
40 | self::assertSame('John', $m->get('name'));
41 |
42 | $this->m->order = [];
43 | $this->m->setOrder('name', 'desc');
44 | $m = $this->m->loadAny();
45 | self::assertSame('Sue', $m->get('name'));
46 |
47 | self::assertSame([2 => 'Sue', 1 => 'John'], $this->m->getTitles());
48 | }
49 |
50 | public function testLoad(): void
51 | {
52 | $m = $this->m->load(1);
53 | self::assertTrue($m->isLoaded());
54 | }
55 |
56 | public function testInsert(): void
57 | {
58 | $this->expectException(Exception::class);
59 | $this->expectExceptionMessage('Model is read-only');
60 | $this->m->insert(['name' => 'Joe']);
61 | }
62 |
63 | public function testSave(): void
64 | {
65 | $m = $this->m->load(1);
66 | $m->set('name', 'X');
67 |
68 | $this->expectException(Exception::class);
69 | $this->expectExceptionMessage('Model is read-only');
70 | $m->save();
71 | }
72 |
73 | public function testSaveAndUnload(): void
74 | {
75 | $m = $this->m->loadAny();
76 |
77 | $this->expectException(Exception::class);
78 | $this->expectExceptionMessage('Model is read-only');
79 | $m->saveAndUnload();
80 | }
81 |
82 | public function testLoadBy(): void
83 | {
84 | $m = $this->m->loadBy('name', 'Sue');
85 | self::assertSame('Sue', $m->get('name'));
86 | }
87 |
88 | public function testLoadCondition(): void
89 | {
90 | $this->m->addCondition('name', 'Sue');
91 | $m = $this->m->loadAny();
92 | self::assertSame('Sue', $m->get('name'));
93 | }
94 |
95 | public function testFailDelete1(): void
96 | {
97 | $this->expectException(Exception::class);
98 | $this->expectExceptionMessage('Model is read-only');
99 | $this->m->delete(1);
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/tests/SerializeTest.php:
--------------------------------------------------------------------------------
1 | db, ['table' => 'job']);
17 | $m->addField('data', ['type' => 'object']);
18 |
19 | self::assertSame(
20 | ['data' => 'O:8:"stdClass":1:{s:3:"foo";s:3:"bar";}'],
21 | $this->db->typecastSaveRow(
22 | $m,
23 | ['data' => (object) ['foo' => 'bar']]
24 | )
25 | );
26 | self::assertSame(
27 | ['data' => ['foo' => 'bar']],
28 | $this->db->typecastLoadRow(
29 | $m,
30 | ['data' => 'a:1:{s:3:"foo";s:3:"bar";}']
31 | )
32 | );
33 |
34 | $m->getField('data')->type = 'json';
35 | self::assertSame(
36 | ['data' => ($this->getDatabasePlatform() instanceof PostgreSQLPlatform ? "atk4_explicit_cast\ru5f8mzx4vsm8g2c9\rjson\r" : '') . '{"foo":"bar"}'],
37 | $this->db->typecastSaveRow(
38 | $m,
39 | ['data' => ['foo' => 'bar']]
40 | )
41 | );
42 | self::assertSame(
43 | ['data' => ['foo' => 'bar']],
44 | $this->db->typecastLoadRow(
45 | $m,
46 | ['data' => '{"foo":"bar"}']
47 | )
48 | );
49 | }
50 |
51 | public function testSerializeErrorJson(): void
52 | {
53 | $m = new Model($this->db, ['table' => 'job']);
54 | $m->addField('data', ['type' => 'json']);
55 |
56 | $this->expectException(Exception::class);
57 | $this->db->typecastLoadRow($m, ['data' => '{"foo":"bar" OPS']);
58 | }
59 |
60 | public function testSerializeErrorJson2(): void
61 | {
62 | $m = new Model($this->db, ['table' => 'job']);
63 | $m->addField('data', ['type' => 'json']);
64 |
65 | // recursive array - json can't encode that
66 | $dbData = [];
67 | $dbData[] = &$dbData;
68 |
69 | $this->expectException(Exception::class);
70 | $this->expectExceptionMessage('Could not convert PHP type \'array\' to \'json\', as an \'Recursion detected\'');
71 | $this->db->typecastSaveRow($m, ['data' => ['foo' => 'bar', 'recursive' => $dbData]]);
72 | }
73 |
74 | public function testSerializeErrorSerialize(): void
75 | {
76 | $m = new Model($this->db, ['table' => 'job']);
77 | $m->addField('data', ['type' => 'object']);
78 |
79 | $this->expectException(Exception::class);
80 | $this->db->typecastLoadRow($m, ['data' => 'a:1:{s:3:"foo";s:3:"bar"; OPS']);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/tests/SmboTransferTest.php:
--------------------------------------------------------------------------------
1 | createMigrator()->table('account')
20 | ->id()
21 | ->field('name')
22 | ->create();
23 |
24 | $this->createMigrator()->table('document')
25 | ->id()
26 | ->field('reference')
27 | ->field('doc_type')
28 | ->field('amount', ['type' => 'float'])
29 | ->create();
30 |
31 | $this->createMigrator()->table('payment')
32 | ->id()
33 | ->field('document_id', ['type' => 'bigint'])
34 | ->field('account_id', ['type' => 'bigint'])
35 | ->field('cheque_no')
36 | ->field('misc_payment', ['type' => 'boolean'])
37 | ->field('transfer_document_id', ['type' => 'bigint'])
38 | ->create();
39 | }
40 |
41 | public function testTransferBetweenAccounts(): void
42 | {
43 | $aib = (new Account($this->db))->createEntity()->save(['name' => 'AIB']);
44 | $boi = (new Account($this->db))->createEntity()->save(['name' => 'BOI']);
45 |
46 | $t = $aib->transfer($boi, 100); // create transfer between accounts
47 | $t->save();
48 |
49 | self::assertSame(-100.0, $aib->reload()->get('balance'));
50 | self::assertSame(100.0, $boi->reload()->get('balance'));
51 |
52 | $t = new Transfer($this->db);
53 | self::assertSameExportUnordered([
54 | ['id' => 1, 'transfer_document_id' => 2],
55 | ['id' => 2, 'transfer_document_id' => 1],
56 | ], $t->export(['id', 'transfer_document_id']));
57 | }
58 |
59 | public function testRef(): void
60 | {
61 | // create accounts and payments
62 | $a = new Account($this->db);
63 |
64 | $aa = $a->createEntity();
65 | $aa->save(['name' => 'AIB']);
66 | $aa->ref('Payment')->createEntity()->save(['amount' => 10]);
67 | $aa->ref('Payment')->createEntity()->save(['amount' => 20]);
68 |
69 | $aa = $a->createEntity();
70 | $aa->save(['name' => 'BOI']);
71 | $aa->ref('Payment')->createEntity()->save(['amount' => 30]);
72 |
73 | // create payment without link to account
74 | $p = new Payment($this->db);
75 | $p->createEntity()->saveAndUnload(['amount' => 40]);
76 |
77 | // Account is not loaded, will dump all Payments related to ANY Account
78 | $data = $a->ref('Payment')->export(['amount']);
79 | self::assertSameExportUnordered([
80 | ['amount' => 10.0],
81 | ['amount' => 20.0],
82 | ['amount' => 30.0],
83 | // ['amount' => 40.0], // will not select this because it is not related to any Account
84 | ], $data);
85 |
86 | // Account is loaded, will dump all Payments related to that particular Account
87 | $a = $a->load(1);
88 | $data = $a->ref('Payment')->export(['amount']);
89 | self::assertSameExportUnordered([
90 | ['amount' => 10.0],
91 | ['amount' => 20.0],
92 | ], $data);
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/tests/ValidationTest.php:
--------------------------------------------------------------------------------
1 | addField('name');
20 | $this->addField('domain');
21 | }
22 |
23 | #[\Override]
24 | public function validate(?string $intent = null): array
25 | {
26 | $errors = [];
27 | if ($this->get('name') === 'Python') {
28 | $errors['name'] = 'Snakes are not allowed on this plane';
29 | }
30 | if ($this->get('domain') === 'example.com') {
31 | $errors['domain'] = 'This domain is reserved for examples only';
32 | }
33 |
34 | return array_merge(parent::validate(), $errors);
35 | }
36 | }
37 |
38 | class BadValidationModel extends Model
39 | {
40 | #[\Override]
41 | protected function init(): void
42 | {
43 | parent::init();
44 |
45 | $this->addField('name');
46 | }
47 |
48 | #[\Override]
49 | public function validate(?string $intent = null): array
50 | {
51 | return 'This should be array'; // @phpstan-ignore return.type
52 | }
53 | }
54 |
55 | class ValidationTest extends TestCase
56 | {
57 | /** @var Model */
58 | public $m;
59 |
60 | #[\Override]
61 | protected function setUp(): void
62 | {
63 | parent::setUp();
64 |
65 | $p = new Persistence\Array_();
66 | $this->m = new MyValidationModel($p);
67 | }
68 |
69 | public function testValidate1(): void
70 | {
71 | $m = $this->m->createEntity();
72 | $m->set('name', 'john');
73 | $m->set('domain', 'gmail.com');
74 | $m->save();
75 | self::assertSame('john', $m->get('name'));
76 | }
77 |
78 | public function testValidate2(): void
79 | {
80 | $m = $this->m->createEntity();
81 | $m->set('name', 'Python');
82 |
83 | $this->expectException(ValidationException::class);
84 | $this->expectExceptionMessage('Snakes are not allowed on this plane');
85 | $m->save();
86 | }
87 |
88 | public function testValidate3(): void
89 | {
90 | $m = $this->m->createEntity();
91 | $m->set('name', 'Python');
92 | $m->set('domain', 'example.com');
93 |
94 | $this->expectException(ValidationException::class);
95 | $this->expectExceptionMessage('Multiple validation errors');
96 | $m->save();
97 | }
98 |
99 | public function testValidate4(): void
100 | {
101 | $m = $this->m->createEntity();
102 | try {
103 | $m->set('name', 'Python');
104 | $m->set('domain', 'example.com');
105 |
106 | $this->expectException(ValidationException::class);
107 | $m->save();
108 | } catch (ValidationException $e) {
109 | self::assertSame('This domain is reserved for examples only', $e->getParams()['errors']['domain']);
110 |
111 | throw $e;
112 | }
113 | }
114 |
115 | public function testValidate5(): void
116 | {
117 | $p = new Persistence\Array_();
118 | $m = new BadValidationModel($p);
119 | $m = $m->createEntity();
120 |
121 | $this->expectException(\TypeError::class);
122 | $m->set('name', 'john');
123 | $m->save();
124 | }
125 |
126 | public function testValidateHook1(): void
127 | {
128 | $m = $this->m->createEntity();
129 | $m->onHook(Model::HOOK_VALIDATE, static function (Model $entity) {
130 | if ($entity->get('name') === 'C#') {
131 | return ['name' => 'No sharp objects allowed'];
132 | }
133 | });
134 |
135 | $m->set('name', 'Swift');
136 | $m->save();
137 |
138 | try {
139 | $m->set('name', 'C#');
140 |
141 | $this->expectException(ValidationException::class);
142 | $m->save();
143 | } catch (ValidationException $e) {
144 | self::assertSame([
145 | 'name' => 'No sharp objects allowed',
146 | ], $e->errors);
147 |
148 | throw $e;
149 | }
150 | }
151 |
152 | public function testValidateHook2(): void
153 | {
154 | $m = $this->m->createEntity();
155 | $m->onHook(Model::HOOK_VALIDATE, static function (Model $entity) {
156 | if ($entity->get('name') === 'C#') {
157 | return ['name' => 'No sharp objects allowed'];
158 | }
159 | });
160 |
161 | $m->set('name', 'Swift');
162 | $m->save();
163 |
164 | try {
165 | $m->set('name', 'Python');
166 | $m->set('domain', 'example.com');
167 |
168 | $this->expectException(ValidationException::class);
169 | $m->save();
170 | } catch (ValidationException $e) {
171 | self::assertSame([
172 | 'name' => 'Snakes are not allowed on this plane',
173 | 'domain' => 'This domain is reserved for examples only',
174 | ], $e->errors);
175 |
176 | throw $e;
177 | }
178 | }
179 | }
180 |
--------------------------------------------------------------------------------