├── .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 | --------------------------------------------------------------------------------