├── .github └── workflows │ ├── publish.yml │ └── quality_gate.yml ├── .gitignore ├── .idea ├── .gitignore ├── libraries │ ├── Dart_Packages.xml │ └── Dart_SDK.xml ├── markdown.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example └── example.dart ├── lib ├── sql_crdt.dart └── src │ ├── crdt_executor.dart │ ├── database_api.dart │ ├── sql_crdt.dart │ └── sql_util.dart ├── pubspec.yaml ├── sql_crdt.iml └── test └── sql_util_test.dart /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to pub.dev 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+*' 7 | 8 | jobs: 9 | publish: 10 | permissions: 11 | id-token: write 12 | uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 13 | -------------------------------------------------------------------------------- /.github/workflows/quality_gate.yml: -------------------------------------------------------------------------------- 1 | name: Quality gate 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '**' 8 | 9 | jobs: 10 | format: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: dart-lang/setup-dart@v1 15 | - run: dart format --set-exit-if-changed . 16 | 17 | analyze: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: dart-lang/setup-dart@v1 22 | - run: dart pub get 23 | - run: dart analyze --fatal-infos 24 | 25 | test: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: dart-lang/setup-dart@v1 30 | - run: dart pub get 31 | - run: dart run test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | 5 | # Avoid committing pubspec.lock for library packages; see 6 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 7 | pubspec.lock 8 | 9 | 10 | # Avoid commiting build dir 11 | build/ -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/libraries/Dart_Packages.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | -------------------------------------------------------------------------------- /.idea/libraries/Dart_SDK.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /.idea/markdown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.0.3 2 | 3 | - Allow anonymous query parameters (Janez Štupar) 4 | - Update dependencies 5 | 6 | ## 3.0.2 7 | 8 | - Update dependencies 9 | 10 | ## 3.0.1 11 | 12 | - Allow setting custom node ids 13 | - Add CrdtApi interface to normalize query and execute methods 14 | 15 | ## 3.0.0+1 16 | 17 | - Fix missing `query` method in transactions 18 | 19 | ## 3.0.0 20 | 21 | Major performance refactor with a few breaking changes 22 | 23 | - Change how tables and primary keys are fetched to minimize reads 24 | - Allow for more efficient bulk writing in underlying implementation 25 | - Rework abstractions to allow exposing Sqlite batches 26 | - Rename classes to better reflect their goals 27 | - Correctly identify and forbid semicolon separated statements 28 | 29 | ## 2.1.7 30 | 31 | - Add support for inserts from select queries 32 | 33 | ## 2.1.6 34 | 35 | - Upgrade sqlparser 36 | 37 | ## 2.1.5+1 38 | 39 | - Remove echoing unrecognized statements 40 | 41 | ## 2.1.5 42 | 43 | - Remove unnecessary warnings 44 | 45 | ## 2.1.4 46 | 47 | - Fix failed inserts and updates with null argument lists 48 | 49 | ## 2.1.3 50 | 51 | - Automatically convert String HLCs when merging records 52 | 53 | ## 2.1.2 54 | 55 | - Fix table filtering 56 | 57 | ## 2.1.1 58 | 59 | - Filter custom query tables when sending delta changesets 60 | 61 | ## 2.1.0 62 | 63 | - Update to latest `crdt` version 64 | 65 | ## 2.0.0 66 | 67 | This version introduces a major refactor which results in multiple breaking changes in line with `crdt` v5. 68 | 69 | This makes this package compatible with all other packages in the [crdt](https://github.com/cachapa/crdt) family, which enables seamles synchronization between them using [crdt_sync](https://github.com/cachapa/crdt_sync). 70 | 71 | Changes: 72 | - Simplify code and API 73 | - Allow specifying custom queries when generating changesets 74 | - Remove superfluous method `watchChangeset` 75 | 76 | ## 1.1.1+1 77 | 78 | - Add `CrdtChangeset` type alias 79 | 80 | ## 1.1.1 81 | 82 | - Fix change notifications when no changes were made 83 | - Guard against a possible race condition when setting the canonical time 84 | 85 | ## 1.1.0 86 | 87 | - Breaking: return Hlc.zero instead of null in `lastModified` 88 | - Breaking: allow specifying nodeIds in `getChangeset` 89 | - Add getter for all tables in database 90 | - Allow watching changed tables to enable atomic sync operations 91 | - Removed convenience getter `peerLastModified` 92 | - Do not merge empty changesets 93 | 94 | ## 1.0.3 95 | 96 | - Update to Dart 3.0 97 | - Fix watch argument generator not being re-run on every query 98 | 99 | ## 1.0.2 100 | 101 | - Print malformed queries when using watch() 102 | 103 | ## 1.0.1 104 | 105 | - Unbreaking: revert to int type for is_deleted column 106 | 107 | ## 1.0.0 108 | 109 | - Breaking: allow using boolean type for is_deleted column 110 | - Make SqlCrdt abstract 111 | 112 | ## 0.0.9+2 113 | 114 | - Add example file 115 | 116 | ## 0.0.9+1 117 | 118 | - Add documentation 119 | 120 | ## 0.0.9 121 | 122 | - Add support for upsert and replace statements 123 | 124 | ## 0.0.8+1 125 | 126 | - Fix the getChangeset query 127 | 128 | ## 0.0.8 129 | 130 | - Dedicated node_id column to improve query performance 131 | - Add method to reset node id 132 | - Improve merge error reporting 133 | 134 | ## 0.0.7 135 | 136 | - Initial version. 137 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this project 2 | 3 | Thank you for your interest in contributing to this project. 4 | 5 | Please be aware of the following guidelines before investing your effort in a pull request: 6 | 7 | #### Avoid trivial contributions 8 | 9 | In order to discourage activity spam, trivial contributions by **first-time contributors** will be rejected: typos, dependency updates, code formatting, etc. 10 | 11 | #### Discuss solutions prior to implementation 12 | 13 | Make sure you open a ticket to discuss your technical approach and your plans before you start working on them. This saves a lot of effort and headache by making sure that your technical solution is valuable to the project. 14 | 15 | #### Avoid refactoring or changing unrelated lines 16 | 17 | Avoid refactoring existing code unless discussed previously. The exception is code touched by `dart format` (see below). 18 | 19 | #### Make sure your code is formatted according to the official style guide 20 | 21 | Firedart uses the official Dart guidelines to ensure consistent code style. Please make sure you run `dart format` when making your contribution. 22 | 23 | #### Implement tests 24 | 25 | If the project includes tests please make sure your contribution doesn't break them. When adding new functionality it is usually good practice to also include explicit tests for it. 26 | 27 | #### Limit the change surface in your PR 28 | 29 | Difficulty in reviewing PRs grows exponentially with the amount of changes, so please try to keep your contributions small. It is usually preferable to submit multiple smaller PRs than a single large one. 30 | 31 | #### Avoid incomplete/draft PRs 32 | 33 | Try to make sure your code is ready for review when submitting a PR. If you absolutely must submit a draft PR, make sure it is clearly marked as such. 34 | 35 | #### Be respectful of everyone's time 36 | 37 | Finally, please understand that participants are rarely being directly compensated for their open source work, so be especially aware of the human on the other side of the screen. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Dart implementation of Conflict-free Replicated Data Types (CRDTs) using SQL databases. 2 | 3 | This project is heavily influenced by James Long's talk [CRTDs for Mortals](https://www.dotconferences.com/2019/12/james-long-crdts-for-mortals) and includes a Dart-native implementation of Hybrid Local Clocks (HLC) based on the paper [Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases](https://cse.buffalo.edu/tech-reports/2014-04.pdf). 4 | 5 | `sql_crdt` is based on [crdt](https://github.com/cachapa/crdt) and the learnings from [Libra](https://libra-app.eu), [StoryArk](https://storyark.eu) and [tudo](https://github.com/cachapa/tudo). 6 | 7 | This package is abstract and implements the base functionality for CRDTs backed by a relational database. Check [sqlite_crdt](https://github.com/cachapa/sqlite_crdt.git) and [postgres_crdt](https://github.com/cachapa/postgres_crdt.git) for usable implementations. 8 | 9 | See also [tudo](https://github.com/cachapa/tudo) for a real-world FOSS implementation. 10 | 11 | This package is compatible with [crdt_sync](https://github.com/cachapa/crdt_sync), a turnkey approach for real-time network synchronization of CRDT nodes. 12 | 13 | ## Notes 14 | 15 | `sql_crdt` is not an ORM. The API is essentially that of a plain old SQL database with a few behavioural changes: 16 | 17 | * Every table gets 3 columns automatically added: `is_deleted`, `hlc`, and `modified`. 18 | * Deleted records aren't actually removed but rather flagged in the `is_deleted` column. 19 | * A reactive `watch` method to subscribe to database queries. 20 | * Convenience methods `getChangeset` and `merge` inherited from the `crdt` package to sync with remote nodes. 21 | 22 | > ⚠ Because deleted records are only flagged as deleted, they may need to be sanitized in order to be compliant with GDPR and similar legislation. 23 | 24 | ## API 25 | 26 | The API is intentionally kept simple with a few methods: 27 | 28 | * `execute` to run non-select SQL queries, e.g. inserts, updates, etc. 29 | * `query` to perform a one-time query. 30 | * `watch` to receive query results whenever the database changes. 31 | * `getChangeset` to generate a serializable changeset of the local database. 32 | * `merge` to apply a remote changeset to the local database. 33 | * `transaction` a blocking mechanism that avoids running simultaneous transactions in async code. 34 | 35 | Check the examples in [sqlite_crdt](https://github.com/cachapa/sqlite_crdt/blob/master/example/example.dart) and [postgres_crdt](https://github.com/cachapa/postgres_crdt/blob/master/example/example.dart) for more details. 36 | 37 | ## Features and bugs 38 | 39 | Please file feature requests and bugs in the [issue tracker](https://github.com/cachapa/sql_crdt/issues). 40 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | 3 | linter: 4 | rules: 5 | prefer_single_quotes: true 6 | prefer_relative_imports: true 7 | unawaited_futures: true 8 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | Future main() async { 2 | print('No example here since this is package is a base implementation that ' 3 | 'depends on a backing SQL database to achieve its functionality.\n\n' 4 | 'See https://github.com/cachapa/sqlite_crdt or ' 5 | 'https://github.com/cachapa/postgres_crdt for actual implementations.'); 6 | } 7 | -------------------------------------------------------------------------------- /lib/sql_crdt.dart: -------------------------------------------------------------------------------- 1 | export 'package:crdt/crdt.dart'; 2 | 3 | export 'src/crdt_executor.dart'; 4 | export 'src/database_api.dart'; 5 | export 'src/sql_crdt.dart'; 6 | -------------------------------------------------------------------------------- /lib/src/crdt_executor.dart: -------------------------------------------------------------------------------- 1 | import 'package:crdt/crdt.dart'; 2 | import 'package:sqlparser/sqlparser.dart'; 3 | import 'package:sqlparser/utils/node_to_text.dart'; 4 | 5 | import 'database_api.dart'; 6 | import 'sql_util.dart'; 7 | 8 | final _sqlEngine = SqlEngine(); 9 | 10 | /// Interface to normalize CRDT `query` and `execute` methods 11 | abstract class CrdtApi { 12 | /// Performs a SQL query with optional [args] and returns the result as a list 13 | /// of column maps. 14 | /// Use "?" placeholders for parameters to avoid injection vulnerabilities: 15 | /// 16 | /// ``` 17 | /// final result = await crdt.query( 18 | /// 'SELECT id, name FROM users WHERE id = ?1', [1]); 19 | /// print(result.isEmpty ? 'User not found' : result.first['name']); 20 | /// ``` 21 | Future>> query(String sql, [List? args]); 22 | 23 | /// Executes a SQL query with an optional [args] list. 24 | /// Use "?" placeholders for parameters to avoid injection vulnerabilities: 25 | /// 26 | /// ``` 27 | /// await crdt.execute( 28 | /// 'INSERT INTO users (id, name) Values (?1, ?2)', [1, 'John Doe']); 29 | /// ``` 30 | Future execute(String sql, [List? args]); 31 | } 32 | 33 | /// Intercepts CREATE TABLE queries to assist with table creation and updates. 34 | /// Does not impact any other query types. 35 | class CrdtTableExecutor extends _CrdtTableExecutor implements CrdtApi { 36 | CrdtTableExecutor(ReadWriteApi super._db); 37 | 38 | @override 39 | Future>> query(String sql, [List? args]) => 40 | (_db as ReadWriteApi) 41 | .query(SqlUtil.transformAutomaticExplicitSql(sql), args); 42 | } 43 | 44 | class _CrdtTableExecutor { 45 | final WriteApi _db; 46 | 47 | _CrdtTableExecutor(this._db); 48 | 49 | Future execute(String sql, [List? args]) async { 50 | // Break query into individual statements 51 | final statements = 52 | (_sqlEngine.parseMultiple(sql).rootNode as SemicolonSeparatedStatements) 53 | .statements; 54 | assert(statements.length == 1, 55 | 'This package does not support compound statements:\n$sql'); 56 | 57 | final statement = statements.first; 58 | 59 | // Bail on "manual" transaction statements 60 | if (statement is BeginTransactionStatement || 61 | statement is CommitStatement) { 62 | throw 'Unsupported statement: ${statement.toSql()}.\nUse transaction() instead.'; 63 | } 64 | 65 | await _executeStatement(statement, args); 66 | } 67 | 68 | Future _executeStatement(Statement statement, List? args) => 69 | statement is CreateTableStatement 70 | ? _createTable(statement, args) 71 | : _execute(statement, args); 72 | 73 | Future _createTable( 74 | CreateTableStatement statement, List? args) async { 75 | final newStatement = CreateTableStatement( 76 | tableName: statement.tableName, 77 | columns: [ 78 | ...statement.columns, 79 | ColumnDefinition( 80 | columnName: 'is_deleted', 81 | typeName: 'INTEGER', 82 | constraints: [Default(null, NumericLiteral(0))], 83 | ), 84 | ColumnDefinition( 85 | columnName: 'hlc', 86 | typeName: 'TEXT', 87 | constraints: [NotNull(null)], 88 | ), 89 | ColumnDefinition( 90 | columnName: 'node_id', 91 | typeName: 'TEXT', 92 | constraints: [NotNull(null)], 93 | ), 94 | ColumnDefinition( 95 | columnName: 'modified', 96 | typeName: 'TEXT', 97 | constraints: [NotNull(null)], 98 | ), 99 | ], 100 | tableConstraints: statement.tableConstraints, 101 | ifNotExists: statement.ifNotExists, 102 | isStrict: statement.isStrict, 103 | withoutRowId: statement.withoutRowId, 104 | ); 105 | 106 | await _execute(newStatement, args); 107 | 108 | return newStatement.tableName; 109 | } 110 | 111 | Future _execute(Statement statement, List? args) async { 112 | final sql = statement is InvalidStatement 113 | ? statement.span?.text 114 | : statement.toSql(); 115 | if (sql == null) return null; 116 | 117 | await _db.execute(sql, args); 118 | return null; 119 | } 120 | } 121 | 122 | class CrdtExecutor extends CrdtWriteExecutor implements CrdtApi { 123 | CrdtExecutor(ReadWriteApi super._db, super.hlc); 124 | 125 | @override 126 | Future>> query(String sql, [List? args]) => 127 | (_db as ReadWriteApi) 128 | .query(SqlUtil.transformAutomaticExplicitSql(sql), args); 129 | } 130 | 131 | class CrdtWriteExecutor extends _CrdtTableExecutor { 132 | final Hlc hlc; 133 | late final _hlcString = hlc.toString(); 134 | 135 | final affectedTables = {}; 136 | 137 | CrdtWriteExecutor(super._db, this.hlc); 138 | 139 | @override 140 | Future _executeStatement( 141 | Statement statement, List? args) async { 142 | SqlUtil.transformAutomaticExplicit(statement); 143 | final table = await switch (statement) { 144 | CreateTableStatement statement => _createTable(statement, args), 145 | InsertStatement statement => _insert(statement, args), 146 | UpdateStatement statement => _update(statement, args), 147 | DeleteStatement statement => _delete(statement, args), 148 | // Else, run the query unchanged 149 | _ => _execute(statement, args) 150 | }; 151 | if (table != null) affectedTables.add(table); 152 | } 153 | 154 | Future _insert(InsertStatement statement, List? args) async { 155 | // Force explicit column description in insert statements 156 | assert(statement.targetColumns.isNotEmpty, 157 | 'Unsupported statement: target columns must be explicitly stated.\n${statement.toSql()}'); 158 | 159 | // Disallow star select statements 160 | assert( 161 | statement.source is! SelectInsertSource || 162 | ((statement.source as SelectInsertSource).stmt as SelectStatement) 163 | .columns 164 | .whereType() 165 | .isEmpty, 166 | 'Unsupported statement: select columns must be explicitly stated.\n${statement.toSql()}'); 167 | 168 | final argCount = args?.length ?? 0; 169 | final source = switch (statement.source) { 170 | ValuesSource s => ValuesSource([ 171 | Tuple(expressions: [ 172 | ...s.values.first.expressions, 173 | NumberedVariable(argCount + 1), 174 | NumberedVariable(argCount + 2), 175 | NumberedVariable(argCount + 3), 176 | ]) 177 | ]), 178 | SelectInsertSource s => SelectInsertSource(SelectStatement( 179 | withClause: (s.stmt as SelectStatement).withClause, 180 | distinct: (s.stmt as SelectStatement).distinct, 181 | columns: [ 182 | ...(s.stmt as SelectStatement).columns, 183 | ExpressionResultColumn(expression: NumberedVariable(argCount + 1)), 184 | ExpressionResultColumn(expression: NumberedVariable(argCount + 2)), 185 | ExpressionResultColumn(expression: NumberedVariable(argCount + 3)), 186 | ], 187 | from: (s.stmt as SelectStatement).from, 188 | where: (s.stmt as SelectStatement).where, 189 | groupBy: (s.stmt as SelectStatement).groupBy, 190 | windowDeclarations: (s.stmt as SelectStatement).windowDeclarations, 191 | orderBy: (s.stmt as SelectStatement).orderBy, 192 | limit: (s.stmt as SelectStatement).limit, 193 | )), 194 | _ => throw UnimplementedError( 195 | 'Unsupported data source: ${statement.source.runtimeType}, please file an issue in the sql_crdt project.') 196 | }; 197 | 198 | final newStatement = InsertStatement( 199 | mode: statement.mode, 200 | upsert: statement.upsert, 201 | returning: statement.returning, 202 | withClause: statement.withClause, 203 | table: statement.table, 204 | targetColumns: [ 205 | ...statement.targetColumns, 206 | Reference(columnName: 'hlc'), 207 | Reference(columnName: 'node_id'), 208 | Reference(columnName: 'modified'), 209 | ], 210 | source: source, 211 | ); 212 | 213 | // Touch 214 | if (statement.upsert is UpsertClause) { 215 | final action = statement.upsert!.entries.first.action; 216 | if (action is DoUpdate) { 217 | action.set.addAll([ 218 | SingleColumnSetComponent( 219 | column: Reference(columnName: 'hlc'), 220 | expression: NumberedVariable(argCount + 1), 221 | ), 222 | SingleColumnSetComponent( 223 | column: Reference(columnName: 'node_id'), 224 | expression: NumberedVariable(argCount + 2), 225 | ), 226 | SingleColumnSetComponent( 227 | column: Reference(columnName: 'modified'), 228 | expression: NumberedVariable(argCount + 3), 229 | ), 230 | ]); 231 | } 232 | } 233 | 234 | args = [...args ?? [], _hlcString, hlc.nodeId, _hlcString]; 235 | await _execute(newStatement, args); 236 | 237 | return newStatement.table.tableName; 238 | } 239 | 240 | Future _update(UpdateStatement statement, List? args) async { 241 | final argCount = args?.length ?? 0; 242 | final newStatement = UpdateStatement( 243 | withClause: statement.withClause, 244 | returning: statement.returning, 245 | from: statement.from, 246 | or: statement.or, 247 | table: statement.table, 248 | set: [ 249 | ...statement.set, 250 | SingleColumnSetComponent( 251 | column: Reference(columnName: 'hlc'), 252 | expression: NumberedVariable(argCount + 1), 253 | ), 254 | SingleColumnSetComponent( 255 | column: Reference(columnName: 'node_id'), 256 | expression: NumberedVariable(argCount + 2), 257 | ), 258 | SingleColumnSetComponent( 259 | column: Reference(columnName: 'modified'), 260 | expression: NumberedVariable(argCount + 3), 261 | ), 262 | ], 263 | where: statement.where, 264 | ); 265 | 266 | args = [...args ?? [], _hlcString, hlc.nodeId, _hlcString]; 267 | await _execute(newStatement, args); 268 | 269 | return newStatement.table.tableName; 270 | } 271 | 272 | Future _delete(DeleteStatement statement, List? args) async { 273 | final argCount = args?.length ?? 0; 274 | final newStatement = UpdateStatement( 275 | returning: statement.returning, 276 | withClause: statement.withClause, 277 | table: statement.table, 278 | set: [ 279 | SingleColumnSetComponent( 280 | column: Reference(columnName: 'is_deleted'), 281 | expression: NumberedVariable(argCount + 1), 282 | ), 283 | SingleColumnSetComponent( 284 | column: Reference(columnName: 'hlc'), 285 | expression: NumberedVariable(argCount + 2), 286 | ), 287 | SingleColumnSetComponent( 288 | column: Reference(columnName: 'node_id'), 289 | expression: NumberedVariable(argCount + 3), 290 | ), 291 | SingleColumnSetComponent( 292 | column: Reference(columnName: 'modified'), 293 | expression: NumberedVariable(argCount + 4), 294 | ), 295 | ], 296 | where: statement.where, 297 | ); 298 | 299 | args = [...args ?? [], 1, _hlcString, hlc.nodeId, _hlcString]; 300 | await _execute(newStatement, args); 301 | 302 | return newStatement.table.tableName; 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /lib/src/database_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | /// Interface representing a "standard" database backend capable of read, write 4 | /// and transaction operations. 5 | abstract class DatabaseApi implements ReadWriteApi { 6 | /// Initiates a transaction in this database. 7 | /// Caution: calls to the parent crdt inside a transaction block will result 8 | /// in a deadlock. 9 | /// 10 | /// await database.transaction((txn) async { 11 | /// // OK 12 | /// await txn.execute('SELECT * FROM users'); 13 | /// 14 | /// // NOT OK: calls to the parent crdt in a transaction 15 | /// // The following code will deadlock 16 | /// await crdt.execute('SELECT * FROM users'); 17 | /// }); 18 | Future transaction(Future Function(ReadWriteApi api) actions); 19 | 20 | /// Executes multiple writes atomically in this database, important for 21 | /// merging large datasets efficiently. 22 | /// 23 | /// Defaults to using transactions but can be extended if a more appropriate 24 | /// method is available, e.g. Sqlite batches or Postgres prepared statements. 25 | Future executeBatch(Future Function(WriteApi api) actions) => 26 | transaction(actions); 27 | } 28 | 29 | /// Interface implementing read and write operations on the underlying database. 30 | abstract class ReadWriteApi implements ReadApi, WriteApi {} 31 | 32 | /// Interface implementing read operations on the underlying database. 33 | abstract class ReadApi { 34 | /// Performs a SQL query with optional [args] and returns the result as a list 35 | /// of column maps. 36 | /// Use "?" placeholders for parameters to avoid injection vulnerabilities: 37 | /// 38 | /// ``` 39 | /// final result = await crdt.query( 40 | /// 'SELECT id, name FROM users WHERE id = ?1', [1]); 41 | /// print(result.isEmpty ? 'User not found' : result.first['name']); 42 | /// ``` 43 | Future>> query(String sql, [List? args]); 44 | } 45 | 46 | /// Interface implementing write operations on the underlying database. 47 | abstract class WriteApi { 48 | /// Executes a SQL query with optional [args]. 49 | /// Use "?" placeholders for parameters to avoid injection vulnerabilities: 50 | /// 51 | /// ``` 52 | /// await crdt.execute( 53 | /// 'INSERT INTO users (id, name) Values (?1, ?2)', [1, 'John Doe']); 54 | /// ``` 55 | FutureOr execute(String sql, [List? args]); 56 | } 57 | -------------------------------------------------------------------------------- /lib/src/sql_crdt.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:crdt/crdt.dart'; 4 | 5 | import 'crdt_executor.dart'; 6 | import 'database_api.dart'; 7 | import 'sql_util.dart'; 8 | 9 | typedef Query = (String sql, List args); 10 | 11 | abstract class SqlCrdt extends Crdt implements CrdtApi { 12 | final DatabaseApi _db; 13 | 14 | // final Map> _tables; 15 | final _watches = >>, _Query>{}; 16 | 17 | /// Make sure you run [init] after instantiation. 18 | SqlCrdt(this._db); 19 | 20 | /// Initialize this CRDT 21 | /// 22 | /// Use [nodeId] to set an explicit id, or leave it empty to autogenerate a 23 | /// random one. 24 | /// If you set a custom id, make sure it's unique accross your system, as 25 | /// collisions will break the CRDT in subtle ways. 26 | /// 27 | /// Setting the node id on init only works for empty CRDTs. 28 | /// See [resetNodeId] and [generateNodeId]. 29 | Future init([String? nodeId]) async { 30 | nodeId ??= generateNodeId(); 31 | // Read the canonical time from database, or start from scratch 32 | canonicalTime = await _getLastModified() ?? Hlc.zero(nodeId); 33 | } 34 | 35 | /// Returns all the user tables in this database. 36 | Future> getTables(); 37 | 38 | /// Returns all the keys for the specified table. 39 | Future> getTableKeys(String table); 40 | 41 | @override 42 | Future>> query(String sql, [List? args]) => 43 | _db.query(SqlUtil.transformAutomaticExplicitSql(sql), args); 44 | 45 | @override 46 | Future execute(String sql, [List? args]) async { 47 | final executor = CrdtExecutor(_db, canonicalTime.increment()); 48 | await executor.execute(sql, args); 49 | await onDatasetChanged(executor.affectedTables, executor.hlc); 50 | } 51 | 52 | /// Initiates a transaction in this database. 53 | /// Caution: calls to the parent crdt inside a transaction block will result 54 | /// in a deadlock. 55 | /// 56 | /// ``` 57 | /// await database.transaction((txn) async { 58 | /// // OK 59 | /// await txn.execute('SELECT * FROM users'); 60 | /// 61 | /// // NOT OK: calls to the parent crdt in a transaction 62 | /// // The following code will deadlock 63 | /// await crdt.execute('SELECT * FROM users'); 64 | /// }); 65 | Future transaction( 66 | Future Function(CrdtExecutor txn) action) async { 67 | late final CrdtExecutor executor; 68 | await _db.transaction((txn) async { 69 | executor = CrdtExecutor(txn, canonicalTime.increment()); 70 | await action(executor); 71 | }); 72 | if (executor.affectedTables.isNotEmpty) { 73 | await onDatasetChanged(executor.affectedTables, executor.hlc); 74 | } 75 | } 76 | 77 | /// Performs a live SQL query with optional [args] and returns the result as a 78 | /// list of column maps. 79 | /// 80 | /// See [query]. 81 | Stream>> watch(String sql, 82 | [List Function()? args]) { 83 | late final StreamController>> controller; 84 | controller = StreamController>>( 85 | onListen: () { 86 | final query = _Query(sql, args); 87 | _watches[controller] = query; 88 | _emitQuery(controller, query); 89 | }, 90 | onCancel: () { 91 | _watches.remove(controller); 92 | controller.close(); 93 | }, 94 | ); 95 | 96 | return controller.stream; 97 | } 98 | 99 | @override 100 | Future getChangeset({ 101 | Map? customQueries, 102 | Iterable? onlyTables, 103 | String? onlyNodeId, 104 | String? exceptNodeId, 105 | Hlc? modifiedOn, 106 | Hlc? modifiedAfter, 107 | }) async { 108 | assert(onlyNodeId == null || exceptNodeId == null); 109 | assert(modifiedOn == null || modifiedAfter == null); 110 | 111 | // Modified times use the local node id 112 | modifiedOn = modifiedOn?.apply(nodeId: nodeId); 113 | modifiedAfter = modifiedAfter?.apply(nodeId: nodeId); 114 | 115 | var tables = onlyTables ?? await getTables(); 116 | 117 | if (customQueries != null) { 118 | // Filter out any tables not explicitly mentioned by custom queries 119 | tables = tables.toSet().intersection(customQueries.keys.toSet()); 120 | } else { 121 | // Use default changeset queries if none are provided 122 | customQueries = { 123 | for (final table in tables) table: ('SELECT * FROM $table', []) 124 | }; 125 | } 126 | 127 | return { 128 | for (final table in tables) 129 | table: await _db.query( 130 | SqlUtil.addChangesetClauses( 131 | table, 132 | customQueries[table]!.$1, 133 | onlyNodeId: onlyNodeId, 134 | exceptNodeId: exceptNodeId, 135 | modifiedOn: modifiedOn, 136 | modifiedAfter: modifiedAfter, 137 | ), 138 | customQueries[table]!.$2), 139 | }; 140 | } 141 | 142 | @override 143 | Future merge(CrdtChangeset changeset) async { 144 | if (changeset.recordCount == 0) return; 145 | 146 | // Validate changeset and highest hlc therein 147 | final hlc = validateChangeset(changeset); 148 | 149 | // Fetch keys for all affected tables 150 | final tableKeys = { 151 | for (final table in changeset.keys) table: await getTableKeys(table) 152 | }; 153 | 154 | // Merge records 155 | await _db.executeBatch((executor) async { 156 | for (final entry in changeset.entries) { 157 | final table = entry.key; 158 | final records = entry.value; 159 | final keys = tableKeys[table]!.join(', '); 160 | 161 | for (final record in records) { 162 | // Extract node id from the record's hlc 163 | record['node_id'] = (record['hlc'] is String 164 | ? (record['hlc'] as String).toHlc 165 | : (record['hlc'] as Hlc)) 166 | .nodeId; 167 | // Ensure hlc and modified are strings 168 | record['hlc'] = record['hlc'].toString(); 169 | record['modified'] = hlc.toString(); 170 | 171 | final columns = record.keys.join(', '); 172 | final placeholders = 173 | List.generate(record.length, (i) => '?${i + 1}').join(', '); 174 | 175 | var i = 1; 176 | final updateStatement = 177 | record.keys.map((e) => '$e = ?${i++}').join(', \n'); 178 | 179 | final sql = ''' 180 | INSERT INTO $table ($columns) 181 | VALUES ($placeholders) 182 | ON CONFLICT ($keys) DO 183 | UPDATE SET $updateStatement 184 | WHERE excluded.hlc > $table.hlc 185 | '''; 186 | await executor.execute(sql, record.values.toList()); 187 | } 188 | } 189 | }); 190 | 191 | await onDatasetChanged(changeset.keys, hlc); 192 | } 193 | 194 | /// Changes the node id. 195 | /// This can be useful e.g. when the user logs out and logs in with a new 196 | /// account without resetting the database - id avoids synchronization issues 197 | /// where the existing entries do not get correctly propagated to the new 198 | /// user id. 199 | /// 200 | /// Use [newNodeId] to set an explicit id, or leave it empty to autogenerate a 201 | /// random one. 202 | /// If you set a custom id, make sure it's unique accross your system, as 203 | /// collisions will break the CRDT in subtle ways. 204 | /// 205 | /// See [init] and [generateNodeId]. 206 | Future resetNodeId([String? newNodeId]) async { 207 | final oldNodeId = canonicalTime.nodeId; 208 | newNodeId ??= generateNodeId(); 209 | await _db.executeBatch( 210 | (txn) async { 211 | for (final table in await getTables()) { 212 | await txn.execute( 213 | 'UPDATE $table SET modified = REPLACE(modified, ?1, ?2)', 214 | [oldNodeId, newNodeId], 215 | ); 216 | } 217 | }, 218 | ); 219 | canonicalTime = canonicalTime.apply(nodeId: newNodeId); 220 | } 221 | 222 | @override 223 | Future onDatasetChanged( 224 | Iterable affectedTables, Hlc hlc) async { 225 | super.onDatasetChanged(affectedTables, hlc); 226 | 227 | for (final entry in _watches.entries.toList()) { 228 | final controller = entry.key; 229 | final query = entry.value; 230 | if (affectedTables 231 | .firstWhere((e) => query.affectedTables.contains(e), orElse: () => '') 232 | .isNotEmpty) { 233 | await _emitQuery(controller, query); 234 | } 235 | } 236 | } 237 | 238 | @override 239 | Future getLastModified( 240 | {String? onlyNodeId, String? exceptNodeId}) async => 241 | (await _getLastModified( 242 | onlyNodeId: onlyNodeId, exceptNodeId: exceptNodeId)) ?? 243 | Hlc.zero(nodeId); 244 | 245 | Future _getLastModified( 246 | {String? onlyNodeId, String? exceptNodeId}) async { 247 | assert(onlyNodeId == null || exceptNodeId == null); 248 | 249 | final tables = await getTables(); 250 | if (tables.isEmpty) return null; 251 | 252 | final whereStatement = onlyNodeId != null 253 | ? 'WHERE node_id = ?1' 254 | : exceptNodeId != null 255 | ? 'WHERE node_id != ?1' 256 | : ''; 257 | final tableStatements = tables.map((table) => 258 | 'SELECT max(modified) AS modified FROM $table $whereStatement'); 259 | final result = await _db.query(''' 260 | SELECT max(modified) AS modified FROM ( 261 | ${tableStatements.join('\nUNION ALL\n')} 262 | ) all_tables 263 | ''', [ 264 | if (onlyNodeId != null) onlyNodeId, 265 | if (exceptNodeId != null) exceptNodeId, 266 | ]); 267 | return (result.firstOrNull?['modified'] as String?)?.toHlc; 268 | } 269 | 270 | Future _emitQuery( 271 | StreamController>> controller, 272 | _Query query) async { 273 | final result = await _db.query(query.sql, query.args?.call()); 274 | if (!controller.isClosed) { 275 | controller.add(result); 276 | } 277 | } 278 | } 279 | 280 | class _Query { 281 | final String sql; 282 | final List Function()? args; 283 | final Set affectedTables; 284 | 285 | _Query(this.sql, this.args) : affectedTables = SqlUtil.getAffectedTables(sql); 286 | } 287 | -------------------------------------------------------------------------------- /lib/src/sql_util.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:crdt/crdt.dart'; 3 | import 'package:source_span/source_span.dart'; 4 | import 'package:sqlparser/sqlparser.dart'; 5 | import 'package:sqlparser/utils/node_to_text.dart'; 6 | 7 | class SqlUtil { 8 | // https://github.com/simolus3/drift/discussions/2560#discussioncomment-6709055 9 | static final _span = SourceFile.fromString('fake').span(0); 10 | static final _sqlEngine = SqlEngine(); 11 | 12 | SqlUtil._(); 13 | 14 | /// Identifies affected tables in a given SQL statement. 15 | static Set getAffectedTables(String sql) { 16 | try { 17 | return _getAffectedTables( 18 | _sqlEngine.parse(sql).rootNode as BaseSelectStatement); 19 | } catch (_) { 20 | print('Error parsing statement: $sql'); 21 | rethrow; 22 | } 23 | } 24 | 25 | static Set _getAffectedTables(AstNode node) { 26 | if (node is TableReference) return {node.tableName}; 27 | return node.allDescendants 28 | .fold({}, (prev, e) => prev..addAll(_getAffectedTables(e))); 29 | } 30 | 31 | /// function takes a SQL [statement] 32 | /// transforms the SQL statement to change parameters from automatic 33 | /// index into parameters with explicit index 34 | static void transformAutomaticExplicit(Statement statement) { 35 | statement.allDescendants 36 | .whereType() 37 | .forEachIndexed((i, ref) { 38 | ref.explicitIndex ??= i + 1; 39 | }); 40 | } 41 | 42 | static String transformAutomaticExplicitSql(String sql) { 43 | final statement = _sqlEngine.parse(sql).rootNode as Statement; 44 | 45 | // if statement is of InvalidStatement type, return the original SQL string 46 | if (statement is InvalidStatement) return sql; 47 | 48 | transformAutomaticExplicit(statement); 49 | return statement.toSql(); 50 | } 51 | 52 | static String addChangesetClauses( 53 | String table, 54 | String sql, { 55 | String? onlyNodeId, 56 | String? exceptNodeId, 57 | Hlc? modifiedOn, 58 | Hlc? modifiedAfter, 59 | }) { 60 | assert(onlyNodeId == null || exceptNodeId == null); 61 | assert(modifiedOn == null || modifiedAfter == null); 62 | 63 | final statement = _sqlEngine.parse(sql).rootNode as SelectStatement; 64 | 65 | final clauses = [ 66 | if (onlyNodeId != null) 67 | _createClause(table, 'node_id', TokenType.equal, onlyNodeId), 68 | if (exceptNodeId != null) 69 | _createClause( 70 | table, 'node_id', TokenType.exclamationEqual, exceptNodeId), 71 | if (modifiedOn != null) 72 | _createClause( 73 | table, 'modified', TokenType.equal, modifiedOn.toString()), 74 | if (modifiedAfter != null) 75 | _createClause( 76 | table, 'modified', TokenType.more, modifiedAfter.toString()), 77 | if (statement.where != null) statement.where!, 78 | ]; 79 | 80 | if (clauses.isNotEmpty) { 81 | statement.where = 82 | clauses.reduce((left, right) => _joinClauses(left, right)); 83 | } 84 | 85 | return statement.toSql(); 86 | } 87 | 88 | static BinaryExpression _createClause( 89 | String table, String column, TokenType operator, String value) => 90 | BinaryExpression( 91 | Reference(columnName: column), 92 | Token(operator, _span), 93 | StringLiteral(value), 94 | ); 95 | 96 | static BinaryExpression _joinClauses(Expression left, Expression right) => 97 | BinaryExpression(left, Token(TokenType.and, _span), right); 98 | } 99 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: sql_crdt 2 | description: Base package for Conflict-free Replicated Data Types (CRDTs) using SQL databases 3 | version: 3.0.3 4 | homepage: https://github.com/cachapa/sql_crdt 5 | repository: https://github.com/cachapa/sql_crdt 6 | issue_tracker: https://github.com/cachapa/sql_crdt/issues 7 | 8 | environment: 9 | sdk: ^3.0.0 10 | 11 | dependencies: 12 | crdt: ^5.1.3 13 | # path: ../crdt 14 | source_span: ^1.10.1 15 | sqlparser: ^0.41.0 16 | collection: ^1.19.1 17 | 18 | dev_dependencies: 19 | lints: any 20 | test: any 21 | -------------------------------------------------------------------------------- /sql_crdt.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/sql_util_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:sql_crdt/sql_crdt.dart'; 2 | import 'package:sql_crdt/src/sql_util.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | Set getAffectedTables(String sql) => SqlUtil.getAffectedTables(sql); 6 | 7 | void main() { 8 | group('Affected tables', () { 9 | test('Simple query', () { 10 | expect(getAffectedTables('SELECT * FROM users'), {'users'}); 11 | }); 12 | 13 | test('Join', () { 14 | expect(getAffectedTables(''' 15 | SELECT * FROM users 16 | JOIN purchases ON users.id = purchases.user_id 17 | '''), {'users', 'purchases'}); 18 | }); 19 | 20 | test('Union', () { 21 | expect(getAffectedTables(''' 22 | SELECT * FROM naughty 23 | UNION ALL 24 | SELECT * FROM nice 25 | '''), {'naughty', 'nice'}); 26 | }); 27 | 28 | test('Intersect', () { 29 | expect(getAffectedTables(''' 30 | SELECT * FROM naughty 31 | INTERSECT 32 | SELECT * FROM nice 33 | '''), {'naughty', 'nice'}); 34 | }); 35 | 36 | test('Subselect', () { 37 | expect(getAffectedTables(''' 38 | SELECT * FROM (SELECT * FROM users) 39 | '''), {'users'}); 40 | }); 41 | 42 | test('All together!', () { 43 | expect(getAffectedTables(''' 44 | SELECT * FROM table1 45 | JOIN table2 ON id1 = id2 46 | UNION 47 | SELECT * FROM ( 48 | SELECT * FROM table3 JOIN table4 ON id3 = id4 49 | INTERSECT 50 | SELECT * FROM (SELECT * FROM table5) 51 | ) 52 | INTERSECT 53 | SELECT * FROM table6 54 | '''), {'table1', 'table2', 'table3', 'table4', 'table5', 'table6'}); 55 | }); 56 | }); 57 | 58 | group('Add changeset clauses', () { 59 | test('Simple query', () { 60 | final sql = SqlUtil.addChangesetClauses('test', 'SELECT * FROM test', 61 | exceptNodeId: 'node_id', modifiedAfter: Hlc.zero('node_id')); 62 | expect(sql, 63 | "SELECT * FROM test WHERE node_id != 'node_id' AND modified > '1970-01-01T00:00:00.000Z-0000-node_id'"); 64 | }); 65 | 66 | test('Simple query with where clause', () { 67 | final sql = SqlUtil.addChangesetClauses( 68 | 'test', 'SELECT * FROM test WHERE a != ?1 and b = ?2', 69 | exceptNodeId: 'node_id', modifiedAfter: Hlc.zero('node_id')); 70 | expect(sql, 71 | "SELECT * FROM test WHERE node_id != 'node_id' AND modified > '1970-01-01T00:00:00.000Z-0000-node_id' AND a != ?1 AND b = ?2"); 72 | }); 73 | }); 74 | 75 | group('Transform automatic to explicit parameters', () { 76 | test('Simple automatic parameters', () { 77 | final sql = 'SELECT * FROM users WHERE name = ? AND age = ?'; 78 | final transformed = SqlUtil.transformAutomaticExplicitSql(sql); 79 | expect(transformed, 'SELECT * FROM users WHERE name = ?1 AND age = ?2'); 80 | }); 81 | 82 | test('Already explicit parameters', () { 83 | final sql = 'SELECT * FROM users WHERE name = ?1 AND age = ?2'; 84 | final transformed = SqlUtil.transformAutomaticExplicitSql(sql); 85 | expect(transformed, 'SELECT * FROM users WHERE name = ?1 AND age = ?2'); 86 | }); 87 | 88 | test('Mixed automatic and explicit parameters', () { 89 | final sql = 90 | 'SELECT * FROM users WHERE name = ? AND age = ?2 AND city = ?'; 91 | final transformed = SqlUtil.transformAutomaticExplicitSql(sql); 92 | expect(transformed, 93 | 'SELECT * FROM users WHERE name = ?1 AND age = ?2 AND city = ?3'); 94 | }); 95 | 96 | test('Multiple automatic parameters in complex query', () { 97 | final sql = '''SELECT * FROM users 98 | JOIN orders ON users.id = orders.user_id 99 | WHERE users.name = ? AND orders.status = ? AND orders.total > ? 100 | '''; 101 | final transformed = SqlUtil.transformAutomaticExplicitSql(sql); 102 | expect(transformed, 103 | 'SELECT * FROM users JOIN orders ON users.id = orders.user_id WHERE users.name = ?1 AND orders.status = ?2 AND orders.total > ?3'); 104 | }); 105 | 106 | test('Automatic parameters in INSERT statement', () { 107 | final sql = 'INSERT INTO users (name, age, city) VALUES (?, ?, ?)'; 108 | final transformed = SqlUtil.transformAutomaticExplicitSql(sql); 109 | expect(transformed, 110 | 'INSERT INTO users (name, age, city) VALUES (?1, ?2, ?3)'); 111 | }); 112 | 113 | test('Automatic parameters in UPDATE statement', () { 114 | final sql = 'UPDATE users SET name = ?, age = ? WHERE id = ?'; 115 | final transformed = SqlUtil.transformAutomaticExplicitSql(sql); 116 | expect(transformed, 'UPDATE users SET name = ?1, age = ?2 WHERE id = ?3'); 117 | }); 118 | }); 119 | } 120 | --------------------------------------------------------------------------------