diff --git a/.gitignore b/.gitignore index 589a652..ee5a63c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .dart_tool/ .packages build/ +.vscode/ pubspec.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 639b842..ef74ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 0.8.0 + +- Added support for serialization through custom annotations +- Improved migration cli and added support for manual migration output to .sql files +- Fixed bug with default views and value decoding + # 0.7.0 - Added `@AutoIncrement()` annotation for auto incrementing values diff --git a/README.md b/README.md index 4b181e9..634aed2 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,25 @@ As mentioned before, when you have two-way relations in your models you must use any cyclic dependencies. `stormberry` can't resolve them for you, however it will warn you if it detects any when trying to [migrate your database schema](#database-migration-tool). +### Serialization + +When using views, you may need serialization capabilities to send them through an api. While stormberry does +not do serialization by itself, it enables you to use your favorite serialization package through custom annotations. + +When specifying a view, add a target annotation to its constructor: + +```dart +@Model( + views: [ + View('SomeView', [/*field modififers*/], MappableClass()) + ] +) +``` + +This uses the '@MappableClass()' annotation from the [`dart_mappable`](https://pub.dev/packages/dart_mappable) package, +which will be placed on the resulting `SomeView` entity class. Check out [this example](https://github.com/schultek/stormberry/tree/develop/test/packages/serialization) to see +how this can be used to generate serialization extensions for these classes. + ## Indexes As an advanced configuration you can specify indexes on your table using the `TableIndex` class. @@ -373,7 +392,7 @@ await db.users.updateOne(UserUpdateRequest(id: 'abc', name: 'Tom')); You can specify a custom query with custom sql by extending the `Query` class. You will then need to implement the `Future apply(Database db, U params)` method. -Additionally to the model tabels, you can query the model views to automatically get all resolved +Additionally to the model tables, you can query the model views to automatically get all resolved relations without needing to do manual joins. Table names are always plural, e.g. `users` and view names are in the format as `complete_user_view`. @@ -389,7 +408,7 @@ Stormberry comes with a database migration tool, to create or update the schema To use this run the following command from the root folder of your project. ``` -dart pub run stormberry +dart pub run stormberry migrate ``` In order to connect to your database, provide the following environment variables: @@ -400,7 +419,10 @@ confirmation before applying the changes or aborting. The tool supported the following options: -- `-db=`: Specify the database name. Tool will ask if not specified. +- `-h`: Shows the available options. +- `--db=`: Specify the database name. Tool will ask if not specified. - `--dry-run`: Logs any changes to the schema without writing to the database, and exists with code 1 if there are any. - `--apply-changes`: Apply any changes without asking for confirmation. +- `-o=`: Specify an output folder. When used, this will output all migration statements to + `.sql` files in this folder instead of applying them to the database. \ No newline at end of file diff --git a/bin/stormberry.dart b/bin/stormberry.dart index 4c8b566..d547efe 100644 --- a/bin/stormberry.dart +++ b/bin/stormberry.dart @@ -1,133 +1,21 @@ -import 'dart:convert'; import 'dart:io'; -import 'dart:isolate'; - -import 'package:collection/collection.dart'; -import 'package:stormberry/stormberry.dart'; -import 'package:yaml/yaml.dart'; - -import 'src/differentiator.dart'; -import 'src/patcher.dart'; -import 'src/schema.dart'; +import 'package:args/command_runner.dart'; +import 'package:stormberry/src/cli/runner.dart'; Future main(List args) async { - bool dryRun = args.contains('--dry-run'); - String? dbName = args.where((a) => a.startsWith('-db=')).map((a) => a.split('=')[1]).firstOrNull; - bool applyChanges = args.contains('--apply-changes'); - - var pubspecYaml = File('pubspec.yaml'); - - if (!pubspecYaml.existsSync()) { - print('Cannot find pubspec.yaml file in current directory.'); + var runner = CommandRunner( + 'stormberry', + 'Tool for migrating your database to the schema generated from your models.', + )..addCommand(MigrateCommand()); + + try { + await runner.run(args); + exit(0); + } on UsageException catch (e) { + print('${e.message}\n${e.usage}'); exit(1); - } - - var packageName = loadYaml(await pubspecYaml.readAsString())['name'] as String; - - var buildYaml = File('build.yaml'); - - if (!buildYaml.existsSync()) { - print('Cannot find build.yaml file in current directory.'); + } catch (e, st) { + print('$e\n$st'); exit(1); } - - var content = loadYaml(await buildYaml.readAsString()); - - List? generateTargets = (content['targets'] as YamlMap?) - ?.values - .map((t) => t['builders']?['stormberry']) - .where((b) => b != null) - .expand((b) => b['generate_for'] as List) - .map((d) => d as String) - .toList(); - - if (generateTargets == null || generateTargets.isEmpty) { - print('Cannot find stormberry generation targets in build.yaml. ' - 'Make sure you have the stormberry builder configured with at least one generation target.'); - exit(1); - } - - var schema = DatabaseSchema.empty(); - - for (var target in generateTargets) { - var schemaPath = target.replaceFirst('.dart', '.runner.g.dart'); - var file = File('.dart_tool/build/generated/$packageName/$schemaPath'); - if (!file.existsSync()) { - print('Could not run migration for target $target. Did you run the build script?'); - exit(1); - } - - var port = ReceivePort(); - await Isolate.spawnUri( - file.absolute.uri, - [], - port.sendPort, - packageConfig: Uri.parse('.packages'), - ); - - var schemaMap = jsonDecode(await port.first as String); - var targetSchema = DatabaseSchema.fromMap(schemaMap as Map); - - schema = schema.mergeWith(targetSchema); - } - - if (dbName == null && Platform.environment['DB_NAME'] == null) { - stdout.write('Select a database to update: '); - dbName = stdin.readLineSync(encoding: Encoding.getByName('utf-8')!); - } - - var db = Database(database: dbName); - - await db.open(); - - print('Getting schema changes of ${db.name}'); - print('========================='); - - var diff = await getSchemaDiff(db, schema); - - if (diff.hasChanges) { - printDiff(diff); - print('========================='); - - if (dryRun) { - print('DATABASE SCHEME HAS CHANGES, EXITING'); - await db.close(); - exit(1); - } else { - await db.startTransaction(); - - String? answerApplyChanges; - if (!applyChanges) { - stdout.write('Do you want to apply these changes? (yes/no): '); - answerApplyChanges = stdin.readLineSync(encoding: Encoding.getByName('utf-8')!); - } - - if (applyChanges || answerApplyChanges == 'yes') { - print('Database schema changed, applying updates now:'); - - try { - db.debugPrint = true; - await patchSchema(db, diff); - } catch (_) { - db.cancelTransaction(); - } - } else { - db.cancelTransaction(); - } - - var updateWasSuccessFull = await db.finishTransaction(); - - print('========================'); - if (updateWasSuccessFull) { - print('---\nDATABASE UPDATE SUCCESSFUL'); - } else { - print('---\nALL CHANGES REVERTED, EXITING'); - } - - await db.close(); - exit(updateWasSuccessFull ? 0 : 1); - } - } else { - print('NO CHANGES, ALL DONE'); - } } diff --git a/build.yaml b/build.yaml index 4efcef8..1cbb9cf 100644 --- a/build.yaml +++ b/build.yaml @@ -17,4 +17,10 @@ builders: post_process_builders: output_cleanup: import: "package:stormberry/builder.dart" - builder_factory: "outputCleanup" \ No newline at end of file + builder_factory: "outputCleanup" +targets: + $default: + builders: + stormberry: + generate_for: + - test/generation_test.dart \ No newline at end of file diff --git a/example/migrations/0_create_tables.sql b/example/migrations/0_create_tables.sql new file mode 100644 index 0000000..0a76a39 --- /dev/null +++ b/example/migrations/0_create_tables.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS "accounts" ( + "id" serial NOT NULL, + "first_name" text NOT NULL, + "last_name" text NOT NULL, + "location" point NOT NULL, + "data" serial NOT NULL, + "company_id" text NULL +); + +CREATE TABLE IF NOT EXISTS "billing_addresses" ( + "account_id" int8 NULL, + "company_id" text NULL, + "city" text NOT NULL, + "postcode" text NOT NULL, + "name" text NOT NULL, + "street" text NOT NULL +); + +CREATE TABLE IF NOT EXISTS "companies" ( + "id" text NOT NULL, + "name" text NOT NULL +); + +CREATE TABLE IF NOT EXISTS "invoices" ( + "account_id" int8 NULL, + "company_id" text NULL, + "id" text NOT NULL, + "title" text NOT NULL, + "invoice_id" text NOT NULL +); + +CREATE TABLE IF NOT EXISTS "parties" ( + "sponsor_id" text NULL, + "id" text NOT NULL, + "name" text NOT NULL, + "date" int8 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "accounts_parties" ( + "account_id" int8 NOT NULL, + "party_id" text NOT NULL +); \ No newline at end of file diff --git a/example/migrations/1_alter_tables.sql b/example/migrations/1_alter_tables.sql new file mode 100644 index 0000000..b1fa582 --- /dev/null +++ b/example/migrations/1_alter_tables.sql @@ -0,0 +1,35 @@ +ALTER TABLE "accounts" + ADD PRIMARY KEY ( "id" ); + +ALTER TABLE "billing_addresses" + ADD UNIQUE ( "account_id" ); + +ALTER TABLE "companies" + ADD PRIMARY KEY ( "id" ); + +ALTER TABLE "invoices" + ADD PRIMARY KEY ( "id" ); + +ALTER TABLE "parties" + ADD PRIMARY KEY ( "id" ); + +ALTER TABLE "accounts_parties" + ADD PRIMARY KEY ( "account_id", "party_id" ); + +ALTER TABLE "accounts" + ADD FOREIGN KEY ( "company_id" ) REFERENCES companies ( "id" ) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_addresses" + ADD FOREIGN KEY ( "account_id" ) REFERENCES accounts ( "id" ) ON DELETE CASCADE ON UPDATE CASCADE, + ADD FOREIGN KEY ( "company_id" ) REFERENCES companies ( "id" ) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "invoices" + ADD FOREIGN KEY ( "account_id" ) REFERENCES accounts ( "id" ) ON DELETE SET NULL ON UPDATE CASCADE, + ADD FOREIGN KEY ( "company_id" ) REFERENCES companies ( "id" ) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "parties" + ADD FOREIGN KEY ( "sponsor_id" ) REFERENCES companies ( "id" ) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "accounts_parties" + ADD FOREIGN KEY ( "account_id" ) REFERENCES accounts ( "id" ) ON DELETE CASCADE ON UPDATE CASCADE, + ADD FOREIGN KEY ( "party_id" ) REFERENCES parties ( "id" ) ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/example/migrations/2_create_views.sql b/example/migrations/2_create_views.sql new file mode 100644 index 0000000..4436d8e --- /dev/null +++ b/example/migrations/2_create_views.sql @@ -0,0 +1,134 @@ +CREATE VIEW billing_addresses_view AS +SELECT "billing_addresses".* +FROM "billing_addresses" +WHERE '_#dbf42d5d7461f07a564bb43b6e8d38ff097ba5ea#_' IS NOT NULL; + +CREATE VIEW owner_invoices_view AS +SELECT "invoices".* +FROM "invoices" +WHERE '_#e5f94a9602353fec4c4d2e04c795cf62ca4b1226#_' IS NOT NULL; + +CREATE VIEW company_parties_view AS +SELECT "parties".* +FROM "parties" +WHERE '_#088e3580cd83cc28fd25aa3ff86b9fc46addc894#_' IS NOT NULL; + +CREATE VIEW member_companies_view AS +SELECT "companies".*, "addresses"."data" as "addresses" +FROM "companies" +LEFT JOIN ( + SELECT "billing_addresses"."company_id", + to_jsonb(array_agg("billing_addresses".*)) as data + FROM "billing_addresses_view" "billing_addresses" + GROUP BY "billing_addresses"."company_id" +) "addresses" +ON "companies"."id" = "addresses"."company_id" +WHERE '_#63bb7d78bea9d92382206080c274b26e8c3e3f1a#_' IS NOT NULL; + +CREATE VIEW company_accounts_view AS +SELECT "accounts".*, array_to_json(ARRAY (( + SELECT * + FROM jsonb_array_elements("parties".data) AS "parties" + WHERE (parties -> 'sponsor_id') = to_jsonb (accounts.company_id) +)) ) AS "parties" +FROM "accounts" +LEFT JOIN ( + SELECT "accounts_parties"."account_id", + to_jsonb(array_agg("parties".*)) as data + FROM "accounts_parties" + LEFT JOIN "company_parties_view" "parties" + ON "parties"."id" = "accounts_parties"."party_id" + GROUP BY "accounts_parties"."account_id" +) "parties" +ON "accounts"."id" = "parties"."account_id" +WHERE '_#e3332abebaed52c92240088408cde459d4269cd1#_' IS NOT NULL; + +CREATE VIEW guest_parties_view AS +SELECT "parties".*, row_to_json("sponsor".*) as "sponsor" +FROM "parties" +LEFT JOIN "member_companies_view" "sponsor" +ON "parties"."sponsor_id" = "sponsor"."id" +WHERE '_#ead223f933e4d5e9d76e71517fdce0d1621491c5#_' IS NOT NULL; + +CREATE VIEW admin_companies_view AS +SELECT "companies".*, "members"."data" as "members", "addresses"."data" as "addresses", "invoices"."data" as "invoices", "parties"."data" as "parties" +FROM "companies" +LEFT JOIN ( + SELECT "accounts"."company_id", + to_jsonb(array_agg("accounts".*)) as data + FROM "company_accounts_view" "accounts" + GROUP BY "accounts"."company_id" +) "members" +ON "companies"."id" = "members"."company_id" +LEFT JOIN ( + SELECT "billing_addresses"."company_id", + to_jsonb(array_agg("billing_addresses".*)) as data + FROM "billing_addresses_view" "billing_addresses" + GROUP BY "billing_addresses"."company_id" +) "addresses" +ON "companies"."id" = "addresses"."company_id" +LEFT JOIN ( + SELECT "invoices"."company_id", + to_jsonb(array_agg("invoices".*)) as data + FROM "owner_invoices_view" "invoices" + GROUP BY "invoices"."company_id" +) "invoices" +ON "companies"."id" = "invoices"."company_id" +LEFT JOIN ( + SELECT "parties"."sponsor_id", + to_jsonb(array_agg("parties".*)) as data + FROM "company_parties_view" "parties" + GROUP BY "parties"."sponsor_id" +) "parties" +ON "companies"."id" = "parties"."sponsor_id" +WHERE '_#466c9600172ef6af2874adf687244613a5a75add#_' IS NOT NULL; + +CREATE VIEW user_accounts_view AS +SELECT "accounts".*, row_to_json("billingAddress".*) as "billingAddress", "invoices"."data" as "invoices", row_to_json("company".*) as "company", "parties"."data" as "parties" +FROM "accounts" +LEFT JOIN "billing_addresses_view" "billingAddress" +ON "accounts"."id" = "billingAddress"."account_id" +LEFT JOIN ( + SELECT "invoices"."account_id", + to_jsonb(array_agg("invoices".*)) as data + FROM "owner_invoices_view" "invoices" + GROUP BY "invoices"."account_id" +) "invoices" +ON "accounts"."id" = "invoices"."account_id" +LEFT JOIN "member_companies_view" "company" +ON "accounts"."company_id" = "company"."id" +LEFT JOIN ( + SELECT "accounts_parties"."account_id", + to_jsonb(array_agg("parties".*)) as data + FROM "accounts_parties" + LEFT JOIN "guest_parties_view" "parties" + ON "parties"."id" = "accounts_parties"."party_id" + GROUP BY "accounts_parties"."account_id" +) "parties" +ON "accounts"."id" = "parties"."account_id" +WHERE '_#afa18427bb7f6bd4f6cf5872d9ae8ecccf56e227#_' IS NOT NULL; + +CREATE VIEW admin_accounts_view AS +SELECT "accounts".*, row_to_json("billingAddress".*) as "billingAddress", "invoices"."data" as "invoices", row_to_json("company".*) as "company", "parties"."data" as "parties" +FROM "accounts" +LEFT JOIN "billing_addresses_view" "billingAddress" +ON "accounts"."id" = "billingAddress"."account_id" +LEFT JOIN ( + SELECT "invoices"."account_id", + to_jsonb(array_agg("invoices".*)) as data + FROM "owner_invoices_view" "invoices" + GROUP BY "invoices"."account_id" +) "invoices" +ON "accounts"."id" = "invoices"."account_id" +LEFT JOIN "member_companies_view" "company" +ON "accounts"."company_id" = "company"."id" +LEFT JOIN ( + SELECT "accounts_parties"."account_id", + to_jsonb(array_agg("parties".*)) as data + FROM "accounts_parties" + LEFT JOIN "guest_parties_view" "parties" + ON "parties"."id" = "accounts_parties"."party_id" + GROUP BY "accounts_parties"."account_id" +) "parties" +ON "accounts"."id" = "parties"."account_id" +WHERE '_#afa18427bb7f6bd4f6cf5872d9ae8ecccf56e227#_' IS NOT NULL; \ No newline at end of file diff --git a/example/pubspec.yaml b/example/pubspec.yaml index bec7009..353ed68 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -8,5 +8,5 @@ dependencies: path: ../ dev_dependencies: - build_runner: ^2.1.7 - lints: ^1.0.1 \ No newline at end of file + build_runner: ^2.2.1 + lints: ^2.0.0 \ No newline at end of file diff --git a/lib/src/builder/generators/delete_generator.dart b/lib/src/builder/generators/delete_generator.dart index 2aa3c74..f4a3253 100644 --- a/lib/src/builder/generators/delete_generator.dart +++ b/lib/src/builder/generators/delete_generator.dart @@ -9,10 +9,10 @@ class DeleteGenerator { @override Future delete(Database db, List<$keyType> keys) async { if (keys.isEmpty) return; - await db.query(""" - DELETE FROM "${table.tableName}" - WHERE "${table.tableName}"."${table.primaryKeyColumn!.columnName}" IN ( \${keys.map((k) => registry.encode(k)).join(',')} ) - """); + await db.query( + 'DELETE FROM "${table.tableName}"\\n' + 'WHERE "${table.tableName}"."${table.primaryKeyColumn!.columnName}" IN ( \${keys.map((k) => registry.encode(k)).join(',')} )', + ); } '''; } diff --git a/lib/src/builder/generators/insert_generator.dart b/lib/src/builder/generators/insert_generator.dart index 6b61f7e..68422a3 100644 --- a/lib/src/builder/generators/insert_generator.dart +++ b/lib/src/builder/generators/insert_generator.dart @@ -94,20 +94,20 @@ class InsertGenerator { var conflictColumns = table.columns .whereType() .where((c) => c != table.primaryKeyColumn && (c is! FieldColumnBuilder || !c.isAutoIncrement)); - onConflictClause = '\nON CONFLICT ( "${table.primaryKeyColumn!.columnName}" ) DO UPDATE SET ' - '${conflictColumns.map((c) => '"${c.columnName}" = EXCLUDED."${c.columnName}"').join(', ')}'; + onConflictClause = '\n\'ON CONFLICT ( "${table.primaryKeyColumn!.columnName}" ) DO UPDATE SET ' + '${conflictColumns.map((c) => '"${c.columnName}" = EXCLUDED."${c.columnName}"').join(', ')}\''; } } else if (table.columns.where((c) => c is ForeignColumnBuilder && c.isUnique).length == 1) { var foreignColumn = table.columns.whereType().first; var conflictColumns = table.columns.whereType().where((c) => !c.isAutoIncrement); - onConflictClause = '\nON CONFLICT ( "${foreignColumn.columnName}" ) DO UPDATE SET ' - '${conflictColumns.map((c) => '"${c.columnName}" = EXCLUDED."${c.columnName}"').join(', ')}'; + onConflictClause = '\n\'ON CONFLICT ( "${foreignColumn.columnName}" ) DO UPDATE SET ' + '${conflictColumns.map((c) => '"${c.columnName}" = EXCLUDED."${c.columnName}"').join(', ')}\''; } else if (table.columns.where((c) => c is ForeignColumnBuilder && c.isUnique).length > 1) { var conflictColumns = table.columns.whereType().where((c) => !c.isAutoIncrement); conflictKeyStatement = 'var conflictKey = requests.isEmpty ? null : ${table.columns.whereType().map((c) => 'requests.first.${c.paramName} != null ? ${c.isUnique ? "'${c.columnName}'" : 'mull'} : ').join()} null;'; - onConflictClause = "\n\${conflictKey != null ? 'ON CONFLICT (\"\$conflictKey\" ) DO UPDATE SET " - "${conflictColumns.map((c) => '"${c.columnName}" = EXCLUDED."${c.columnName}"').join(', ')}' : ''}"; + onConflictClause = "\n'\${conflictKey != null ? 'ON CONFLICT (\"\$conflictKey\" ) DO UPDATE SET " + "${conflictColumns.map((c) => '"${c.columnName}" = EXCLUDED."${c.columnName}"').join(', ')}' : ''}'"; } var insertColumns = table.columns.whereType(); @@ -118,10 +118,10 @@ class InsertGenerator { if (requests.isEmpty) return${keyReturnStatement != null ? ' []' : ''}; ${autoIncrementStatement ?? ''} ${conflictKeyStatement ?? ''} - await db.query(""" - INSERT INTO "${table.tableName}" ( ${insertColumns.map((c) => '"${c.columnName}"').join(', ')} ) - VALUES \${requests.map((r) => '( ${insertColumns.map((c) => c is FieldColumnBuilder && c.isAutoIncrement ? '\${registry.encode(autoIncrements[requests.indexOf(r)][\'${c.columnName}\'])}' : '\${registry.encode(r.${c.paramName})}').join(', ')} )').join(', ')}${onConflictClause ?? ''} - """); + await db.query( + 'INSERT INTO "${table.tableName}" ( ${insertColumns.map((c) => '"${c.columnName}"').join(', ')} )\\n' + 'VALUES \${requests.map((r) => '( ${insertColumns.map((c) => c is FieldColumnBuilder && c.isAutoIncrement ? '\${registry.encode(autoIncrements[requests.indexOf(r)][\'${c.columnName}\'])}' : '\${registry.encode(r.${c.paramName})}').join(', ')} )').join(', ')}\\n'${onConflictClause ?? ''}, + ); ${deepInserts.isNotEmpty ? deepInserts.join() : ''} ${keyReturnStatement ?? ''} } @@ -160,6 +160,7 @@ class InsertGenerator { } return ''' + ${table.insertRequestAnnotation ?? ''} class $requestClassName { $requestClassName({${requestFields.map((f) => '${f.key.endsWith('?') ? '' : 'required '}this.${f.value}').join(', ')}}); ${requestFields.map((f) => '${f.key} ${f.value};').join('\n')} diff --git a/lib/src/builder/generators/repository_generator.dart b/lib/src/builder/generators/repository_generator.dart index 9b3e23d..197edd5 100644 --- a/lib/src/builder/generators/repository_generator.dart +++ b/lib/src/builder/generators/repository_generator.dart @@ -10,7 +10,7 @@ class RepositoryGenerator { String generateRepositories(BuilderState state) { return ''' extension Repositories on Database { - ${state.builders.values.map((b) => ' ${b.element.name}Repository get ${CaseStyle.camelCase.transform(b.tableName)} => ${b.element.name}Repository._(this);\n').join()} + ${state.builders.values.map((b) => ' ${b.element.name}Repository get ${CaseStyle.camelCase.transform(b.className)} => ${b.element.name}Repository._(this);\n').join()} } final registry = ModelRegistry({ diff --git a/lib/src/builder/generators/update_generator.dart b/lib/src/builder/generators/update_generator.dart index e1983fd..f636117 100644 --- a/lib/src/builder/generators/update_generator.dart +++ b/lib/src/builder/generators/update_generator.dart @@ -69,13 +69,13 @@ class UpdateGenerator { @override Future update(Database db, List<${table.element.name}UpdateRequest> requests) async { if (requests.isEmpty) return; - await db.query(""" - UPDATE "${table.tableName}" - SET ${setColumns.map((c) => '"${c.columnName}" = COALESCE(UPDATED."${c.columnName}"::${c.sqlType}, "${table.tableName}"."${c.columnName}")').join(', ')} - FROM ( VALUES \${requests.map((r) => '( ${updateColumns.map((c) => '\${registry.encode(r.${c.paramName})}').join(', ')} )').join(', ')} ) - AS UPDATED(${updateColumns.map((c) => '"${c.columnName}"').join(', ')}) - WHERE ${hasPrimaryKey ? '"${table.tableName}"."${table.primaryKeyColumn!.columnName}" = UPDATED."${table.primaryKeyColumn!.columnName}"' : table.columns.whereType().map((c) => '"${table.tableName}"."${c.columnName}" = UPDATED."${c.columnName}"').join(' AND ')} - """); + await db.query( + 'UPDATE "${table.tableName}"\\n' + 'SET ${setColumns.map((c) => '"${c.columnName}" = COALESCE(UPDATED."${c.columnName}"::${c.sqlType}, "${table.tableName}"."${c.columnName}")').join(', ')}\\n' + 'FROM ( VALUES \${requests.map((r) => '( ${updateColumns.map((c) => '\${registry.encode(r.${c.paramName})}').join(', ')} )').join(', ')} )\\n' + 'AS UPDATED(${updateColumns.map((c) => '"${c.columnName}"').join(', ')})\\n' + 'WHERE ${hasPrimaryKey ? '"${table.tableName}"."${table.primaryKeyColumn!.columnName}" = UPDATED."${table.primaryKeyColumn!.columnName}"' : table.columns.whereType().map((c) => '"${table.tableName}"."${c.columnName}" = UPDATED."${c.columnName}"').join(' AND ')}', + ); ${deepUpdates.isNotEmpty ? deepUpdates.join() : ''} } '''; @@ -105,7 +105,7 @@ class UpdateGenerator { (column == table.primaryKeyColumn ? '' : '?'), column.paramName)); } else if (column is ForeignColumnBuilder) { - var fieldNullSuffix = column.isNullable ? '?' : ''; + var fieldNullSuffix = column == table.primaryKeyColumn ? '' : '?'; String fieldType; if (column.linkBuilder.primaryKeyColumn == null) { fieldType = column.linkBuilder.element.name; @@ -120,6 +120,7 @@ class UpdateGenerator { } return ''' + ${table.updateRequestAnnotation ?? ''} class $requestClassName { $requestClassName({${requestFields.map((f) => '${f.key.endsWith('?') ? '' : 'required '}this.${f.value}').join(', ')}}); ${requestFields.map((f) => '${f.key} ${f.value};').join('\n')} diff --git a/lib/src/builder/generators/view_generator.dart b/lib/src/builder/generators/view_generator.dart index 894d63e..fd300d7 100644 --- a/lib/src/builder/generators/view_generator.dart +++ b/lib/src/builder/generators/view_generator.dart @@ -9,27 +9,26 @@ class ViewGenerator { var str = StringBuffer(); for (var view in table.views) { - var viewClassName = view.className; var viewName = view.viewName; if (table.primaryKeyColumn != null) { var paramType = table.primaryKeyColumn!.dartType; var paramName = table.primaryKeyColumn!.paramName; - var signature = 'Future<$viewClassName?> query$viewName($paramType $paramName)'; + var signature = 'Future<${view.entityName}?> query$viewName($paramType $paramName)'; if (abstract) { str.writeln('$signature;'); } else { str.writeln( - '@override $signature {\nreturn queryOne($paramName, ${view.className}Queryable());\n}', + '@override $signature {\nreturn queryOne($paramName, ${view.entityName}Queryable());\n}', ); } } - var signature = 'Future> query${viewName}s([QueryParams? params])'; + var signature = 'Future> query${viewName}s([QueryParams? params])'; if (abstract) { str.writeln('$signature;'); } else { - str.writeln('@override $signature {\nreturn queryMany(${view.className}Queryable(), params);\n}'); + str.writeln('@override $signature {\nreturn queryMany(${view.entityName}Queryable(), params);\n}'); } } @@ -66,6 +65,7 @@ class ViewGenerator { ${view.entityName} decode(TypedMap map) => ${view.className}(${view.columns.map((c) => '${c.paramName}: ${_getInitializer(c)}').join(',')}); } + ${view.targetAnnotation ?? ''} class ${view.className}${implementsBase ? ' implements ${view.table.element.name}' : ''} { ${view.className}(${view.columns.isEmpty ? '' : '{${view.columns.map((c) => '${c.isNullable ? '' : 'required '}this.${c.paramName}').join(', ')}}'}); diff --git a/lib/src/builder/stormberry_builder.dart b/lib/src/builder/stormberry_builder.dart index 870b900..bb66ad0 100644 --- a/lib/src/builder/stormberry_builder.dart +++ b/lib/src/builder/stormberry_builder.dart @@ -61,6 +61,8 @@ class StormberryBuilder implements Builder { Map generate(List libraries, BuildStep buildStep) { BuilderState state = BuilderState(options); + state.imports.add(Uri.parse('package:stormberry/internals.dart')); + for (var library in libraries) { if (library.isInSdk) { continue; @@ -99,8 +101,7 @@ class StormberryBuilder implements Builder { map['.output.g.dart'] = DartFormatter(pageWidth: 120).format(''' // ignore_for_file: prefer_relative_imports - import 'package:stormberry/internals.dart'; - ${state.imports.map((i) => "import '$i';").join('\n')} + ${writeImports(state.imports, buildStep.inputId)} ${RepositoryGenerator().generateRepositories(state)} '''); diff --git a/lib/src/builder/table_builder.dart b/lib/src/builder/table_builder.dart index 06104b3..904aa55 100644 --- a/lib/src/builder/table_builder.dart +++ b/lib/src/builder/table_builder.dart @@ -20,13 +20,17 @@ class TableBuilder { ConstantReader annotation; BuilderState state; + late String className; late String tableName; late FieldElement? primaryKeyParameter; late List views; late List indexes; + String? insertRequestAnnotation; + String? updateRequestAnnotation; TableBuilder(this.element, this.annotation, this.state) { tableName = _getTableName(); + className = _getClassName(); primaryKeyParameter = element.fields .where((p) => primaryKeyChecker.hasAnnotationOf(p) || primaryKeyChecker.hasAnnotationOf(p.getter ?? p)) @@ -36,12 +40,32 @@ class TableBuilder { return ViewBuilder(this, o); }).toList(); + if (views.isEmpty) { + views.add(ViewBuilder(this, null)); + } + indexes = annotation.read('indexes').listValue.map((o) { return IndexBuilder(this, o); }).toList(); + + if (!annotation.read('insertRequestAnnotation').isNull) { + insertRequestAnnotation = '@' + annotation.read('insertRequestAnnotation').toSource(); + } + + if (!annotation.read('updateRequestAnnotation').isNull) { + updateRequestAnnotation = '@' + annotation.read('updateRequestAnnotation').toSource(); + } } String _getTableName({bool singular = false}) { + if (!annotation.read('tableName').isNull) { + return annotation.read('tableName').stringValue; + } + + return _getClassName(singular: singular); + } + + String _getClassName({bool singular = false}) { var name = element.name; if (!singular) { if (element.name.endsWith('s')) { @@ -119,7 +143,7 @@ class TableBuilder { ReferencingColumnBuilder otherColumn; - if (selfHasKey && (otherParam == null || !otherParam.type.isDartCoreList)) { + if (selfHasKey && otherParam != null && !otherParam.type.isDartCoreList) { otherColumn = ForeignColumnBuilder(otherParam, this, otherBuilder, state); var insertIndex = otherBuilder.columns.lastIndexWhere((c) => c is ForeignColumnBuilder) + 1; otherBuilder.columns.insert(insertIndex, otherColumn); diff --git a/lib/src/builder/utils.dart b/lib/src/builder/utils.dart index 10f7b1a..ecd74ca 100644 --- a/lib/src/builder/utils.dart +++ b/lib/src/builder/utils.dart @@ -1,8 +1,10 @@ import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; +import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; - +import 'package:path/path.dart' as path; import '../../internals.dart'; import '../core/case_style.dart'; @@ -63,3 +65,98 @@ String? getAnnotationCode(Element annotatedElement, Type annotationType, String return null; } + +extension ObjectSource on DartObject { + String toSource() { + return ConstantReader(this).toSource(); + } +} + +extension ReaderSource on ConstantReader { + String toSource() { + if (isLiteral) { + if (isString) { + return "'$literalValue'"; + } + return literalValue!.toString(); + } + + var rev = revive(); + + var str = ''; + if (rev.source.fragment.isNotEmpty) { + str = rev.source.fragment; + + if (rev.accessor.isNotEmpty) { + str += '.${rev.accessor}'; + } + str += '('; + var isFirst = true; + + for (var p in rev.positionalArguments) { + if (!isFirst) { + str += ', '; + } + isFirst = false; + str += p.toSource(); + } + + for (var p in rev.namedArguments.entries) { + if (!isFirst) { + str += ', '; + } + isFirst = false; + str += '${p.key}: ${p.value.toSource()}'; + } + + str += ')'; + } else { + str = rev.accessor; + } + return str; + } +} + +String writeImports(Set imports, AssetId input) { + + List sdk = [], package = [], relative = []; + + for (var import in imports) { + if (import.isScheme('asset')) { + var relativePath = + path.relative(import.path, from: path.dirname(input.uri.path)); + + relative.add(relativePath); + } else if (import.isScheme('package') && + import.pathSegments.first == input.package && + input.pathSegments.first == 'lib') { + var libPath = + import.replace(pathSegments: import.pathSegments.skip(1)).path; + + var inputPath = input.uri + .replace(pathSegments: input.uri.pathSegments.skip(1)) + .path; + + var relativePath = + path.relative(libPath, from: path.dirname(inputPath)); + + relative.add(relativePath); + } else if (import.scheme == 'dart') { + sdk.add(import.toString()); + } else if (import.scheme == 'package') { + package.add(import.toString()); + } else { + relative.add(import.toString()); + } + } + + sdk.sort(); + package.sort(); + relative.sort(); + + String joined(List s) => s.isNotEmpty + ? '${s.map((s) => "import '$s';").join('\n')}\n\n' + : ''; + + return joined(sdk) + joined(package) + joined(relative); +} \ No newline at end of file diff --git a/lib/src/builder/view_builder.dart b/lib/src/builder/view_builder.dart index 49fddec..feabb87 100644 --- a/lib/src/builder/view_builder.dart +++ b/lib/src/builder/view_builder.dart @@ -1,6 +1,7 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:source_gen/source_gen.dart'; import '../../internals.dart'; import '../core/case_style.dart'; @@ -34,9 +35,9 @@ class ViewColumn { var c = column; if (c is LinkedColumnBuilder) { if (viewAs != null) { - return c.linkBuilder.views.firstWhere((v) => v.name == viewAs!.toLowerCase()); + return c.linkBuilder.views.firstWhere((v) => v.name.toLowerCase() == viewAs!.toLowerCase()); } else { - return c.linkBuilder.views.firstWhere((v) => v.name.isEmpty); + return c.linkBuilder.views.firstWhere((v) => v.isDefaultView); } } return null; @@ -83,15 +84,18 @@ class ViewBuilder { ViewBuilder(this.table, this.annotation); - String get name => annotation?.getField('name')!.toStringValue()!.toLowerCase() ?? ''; + String get name => annotation?.getField('name')!.toStringValue() ?? ''; + + bool get isDefaultView => name.isEmpty; + String get className => CaseStyle.pascalCase - .transform(name.isNotEmpty ? '${name}_${table.element.name}_view' : '${table.element.name}_view'); + .transform('${!isDefaultView ? '${name}_' : ''}${table.element.name}_view'); - String get entityName => name.isEmpty ? table.element.name : className; + String get entityName => isDefaultView ? table.element.name : className; - String get viewName => CaseStyle.pascalCase.transform(name.isNotEmpty ? '${name}_view' : 'view'); + String get viewName => CaseStyle.pascalCase.transform(isDefaultView ? entityName : '${name}_view'); - String get viewTableName => name.isNotEmpty ? '${name}_${table.tableName}_view' : '${table.tableName}_view'; + String get viewTableName => CaseStyle.snakeCase.transform('${!isDefaultView ? '${name}_' : ''}${table.tableName}_view'); List? _columns; List get columns => _columns ??= _getViewColumns(); @@ -124,7 +128,7 @@ class ViewBuilder { var viewAs = viewField.getField('viewAs')!.toStringValue(); if (viewAs == null && column is LinkedColumnBuilder) { - if (!column.linkBuilder.views.any((v) => v.name.isEmpty)) { + if (!column.linkBuilder.views.any((v) => v.isDefaultView)) { column.linkBuilder.views.add(ViewBuilder(column.linkBuilder, null)); } } @@ -143,7 +147,7 @@ class ViewBuilder { .whereType() .firstWhere((node) => node.methodName.name == 'View' && - (node.argumentList.arguments.first as StringLiteral).stringValue?.toLowerCase() == name) + (node.argumentList.arguments.first as StringLiteral).stringValue?.toLowerCase() == name.toLowerCase()) .argumentList .arguments[1]; if (fields is ListLiteral) { @@ -168,7 +172,7 @@ class ViewBuilder { columns.add(ViewColumn(column, viewAs: viewAs, transformer: transformerCode)); } else { if (column is LinkedColumnBuilder) { - if (!column.linkBuilder.views.any((v) => v.name.isEmpty)) { + if (!column.linkBuilder.views.any((v) => v.isDefaultView)) { column.linkBuilder.views.add(ViewBuilder(column.linkBuilder, null)); } } @@ -179,4 +183,11 @@ class ViewBuilder { return columns; } -} + + String? get targetAnnotation { + if (annotation != null && !annotation!.getField('annotation')!.isNull) { + return '@' + annotation!.getField('annotation')!.toSource(); + } + return null; + } +} \ No newline at end of file diff --git a/bin/src/differentiator.dart b/lib/src/cli/differentiator.dart similarity index 83% rename from bin/src/differentiator.dart rename to lib/src/cli/differentiator.dart index 5de0f55..e72c30c 100644 --- a/bin/src/differentiator.dart +++ b/lib/src/cli/differentiator.dart @@ -1,5 +1,5 @@ import 'package:collection/collection.dart'; -import 'package:stormberry/stormberry.dart'; +import '../../stormberry.dart'; import 'inspector.dart'; import 'schema.dart'; @@ -46,20 +46,6 @@ Future getSchemaDiff(Database db, DatabaseSchema dbSchema) a tableDiff.constraints.added.add(newConstraint); } - for (var extTrigger in extTable.triggers) { - var newTrigger = newTable.triggers.where((t) => t == extTrigger).firstOrNull; - - if (newTrigger != null) { - newTable.triggers.remove(newTrigger); - } else { - tableDiff.triggers.removed.add(extTrigger); - } - } - - for (var newTrigger in newTable.triggers) { - tableDiff.triggers.added.add(newTrigger); - } - for (var extIndex in extTable.indexes) { var newIndex = newTable.indexes.where((t) => t == extIndex).firstOrNull; @@ -134,16 +120,6 @@ void printDiff(DatabaseSchemaDiff diff) { print("-- CONSTRAINT ON ${table.name} ${constr.toString().replaceAll(RegExp("[\n\\s]+"), " ")}"); } - for (var trigger in table.triggers.added) { - print('++ TRIGGER ${trigger.name} ON ${table.name}.${trigger.column} ' - "EXECUTE ${trigger.function}(${trigger.args.join(", ")})"); - } - - for (var trigger in table.triggers.removed) { - print('-- TRIGGER ${trigger.name} ON ${table.name}.${trigger.column} ' - "EXECUTE ${trigger.function}(${trigger.args.join(", ")})"); - } - for (var index in table.indexes.added) { print("++ ${index.statement(table.name).replaceAll(RegExp("[\n\\s]+"), " ")}"); } @@ -182,13 +158,11 @@ class TableSchemaDiff { String name; Diff> columns = Diff(); Diff constraints = Diff(); - Diff triggers = Diff(); Diff indexes = Diff(); TableSchemaDiff(this.name); - bool get hasChanges => - columns.hasChanges() || constraints.hasChanges() || triggers.hasChanges() || indexes.hasChanges(); + bool get hasChanges => columns.hasChanges() || constraints.hasChanges() || indexes.hasChanges(); } class Diff { diff --git a/bin/src/inspector.dart b/lib/src/cli/inspector.dart similarity index 81% rename from bin/src/inspector.dart rename to lib/src/cli/inspector.dart index c9dc31e..a54971e 100644 --- a/bin/src/inspector.dart +++ b/lib/src/cli/inspector.dart @@ -1,4 +1,4 @@ -import 'package:stormberry/stormberry.dart'; +import '../../stormberry.dart'; import 'schema.dart'; @@ -12,7 +12,7 @@ Future inspectDatabaseSchema(Database db) async { var tableMap = row.toColumnMap(); var tableName = tableMap['table_name'] as String; - var tableScheme = TableSchema(tableName, columns: {}, constraints: [], triggers: [], indexes: []); + var tableScheme = TableSchema(tableName, columns: {}, constraints: [], indexes: []); schema.tables[tableName] = tableScheme; var columns = await db.query("SELECT * FROM information_schema.columns WHERE table_name = '$tableName'"); @@ -78,29 +78,6 @@ Future inspectDatabaseSchema(Database db) async { } } - var triggers = await db.query(""" - SELECT t.trigger_name, t.event_object_table, t.action_statement, t.action_orientation, t.action_timing, - array_to_json(ARRAY_AGG(t.event_manipulation)) as events, - array_to_json(ARRAY_AGG(tuc.event_object_column)) as columns - FROM information_schema.triggers t - LEFT JOIN information_schema.triggered_update_columns tuc - ON t.trigger_name = tuc.trigger_name AND t.event_manipulation = 'UPDATE' - GROUP BY t.trigger_name, t.event_object_table, t.action_statement, t.action_orientation, t.action_timing - """); - for (var row in triggers) { - var triggerMap = row.toColumnMap(); - var tableName = triggerMap['event_object_table'] as String; - var statement = triggerMap['action_statement'] as String; - var match = RegExp(r'^EXECUTE FUNCTION ([\w_]+)\((.*)\)$').firstMatch(statement)!; - - schema.tables[tableName]?.triggers.add(TableTrigger( - triggerMap['trigger_name'] as String, - triggerMap['columns'].firstWhere((c) => c != null) as String, - match.group(1)!, - match.group(2)!.split(', ').map((a) => a.substring(1, a.length - 1)).toList(), - )); - } - var indexes = await db.query(r""" SELECT * FROM pg_catalog.pg_indexes WHERE schemaname = 'public' AND indexname LIKE '\_\_%' """); diff --git a/lib/src/cli/output.dart b/lib/src/cli/output.dart new file mode 100644 index 0000000..a6a0e61 --- /dev/null +++ b/lib/src/cli/output.dart @@ -0,0 +1,235 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'differentiator.dart'; +import 'schema.dart'; + +var fileIndex = 0; + +Future writeFile(Directory dir, String name, String content) { + var file = File(path.join(dir.path, '${fileIndex++}_$name.sql')); + print('Writing file ${file.path}'); + return file.writeAsString(content); +} + +Future outputSchema(Directory dir, DatabaseSchemaDiff diff) async { + if (diff.tables.added.isNotEmpty) { + var createTables = ''; + + for (var table in diff.tables.added) { + if (createTables.isNotEmpty) { + createTables += '\n\n'; + } + createTables += '' + 'CREATE TABLE IF NOT EXISTS "${table.name}" (\n' + '${table.columns.values.map((c) => ' "${c.name}" ${c.type} ${c.isNullable ? 'NULL' : 'NOT NULL'}').join(",\n")}\n' + ');'; + } + + await writeFile(dir, 'create_tables', createTables); + } + + var alterTables = ''; + void appendStatement(String statement) { + if (alterTables.isNotEmpty) { + alterTables += '\n\n'; + } + alterTables += statement; + } + + for (var table in diff.tables.modified) { + for (var index in table.indexes.removed) { + appendStatement('DROP INDEX "__${index.name}";'); + } + + if (table.constraints.removed.isNotEmpty) { + appendStatement('' + 'ALTER TABLE "${table.name}"\n' + ' ${table.constraints.removed.map((c) => 'DROP CONSTRAINT IF EXISTS "${c.name}" CASCADE').join(",\n ")};'); + } + } + + for (var table in diff.tables.modified) { + if (table.columns.added.isNotEmpty || table.columns.modified.isNotEmpty) { + var updatedColumns = [ + ...table.columns.added.map((c) { + return 'ADD COLUMN "${c.name}" ${c.type} ${c.isNullable ? 'NULL' : 'NOT NULL'}'; + }), + ...table.columns.modified.expand((c) sync* { + if (c.prev.type != 'serial' && c.newly.type == 'serial') { + yield 'ALTER COLUMN "${c.prev.name}" SET DATA TYPE int8 USING ${c.newly.name}::int8'; + yield "ALTER COLUMN \"${c.prev.name}\" SET DEFAULT nextval('${table.name}_${c.newly.name}_seq')"; + } else { + var update = c.prev.type != c.newly.type + ? 'SET DATA TYPE ${c.newly.type} USING ${c.newly.name}::${c.newly.type}' + : '${c.newly.isNullable ? 'DROP' : 'SET'} NOT NULL'; + yield 'ALTER COLUMN "${c.prev.name}" $update'; + } + }), + ]; + + for (var c in table.columns.modified.where((c) => c.prev.type != 'serial' && c.newly.type == 'serial')) { + appendStatement('CREATE SEQUENCE IF NOT EXISTS ${table.name}_${c.newly.name}_seq ' + 'OWNED BY "public"."${table.name}"."${c.newly.name}";'); + } + + appendStatement('ALTER TABLE "${table.name}"\n' + ' ${updatedColumns.join(",\n ")};'); + } + } + + for (var table in diff.tables.modified) { + var uniqueConstraints = + table.constraints.added.where((c) => c is PrimaryKeyConstraint || c is UniqueConstraint).toList(); + if (uniqueConstraints.isNotEmpty) { + appendStatement('ALTER TABLE "${table.name}"\n' + ' ${uniqueConstraints.map((c) => 'ADD ${c.toString()}').join(",\n ")};'); + } + } + + for (var table in diff.tables.added) { + var uniqueConstraints = table.constraints.where((c) => c is PrimaryKeyConstraint || c is UniqueConstraint).toList(); + if (uniqueConstraints.isNotEmpty) { + appendStatement('ALTER TABLE "${table.name}"\n' + ' ${uniqueConstraints.map((c) => 'ADD ${c.toString()}').join(",\n ")};'); + } + } + + for (var table in diff.tables.modified) { + var foreignKeyConstraints = table.constraints.added.whereType().toList(); + if (foreignKeyConstraints.isNotEmpty) { + appendStatement('ALTER TABLE "${table.name}"\n' + ' ${foreignKeyConstraints.map((c) => 'ADD ${c.toString()}').join(",\n ")};'); + } + } + + for (var table in diff.tables.added) { + var foreignKeyConstraints = table.constraints.whereType().toList(); + if (foreignKeyConstraints.isNotEmpty) { + appendStatement('ALTER TABLE "${table.name}"\n' + ' ${foreignKeyConstraints.map((c) => 'ADD ${c.toString()}').join(",\n ")};'); + } + } + + for (var table in diff.tables.modified) { + for (var index in table.indexes.added) { + appendStatement('CREATE ${index.statement(table.name)};'); + } + } + + for (var table in diff.tables.added) { + for (var index in table.indexes) { + appendStatement('CREATE ${index.statement(table.name)};'); + } + } + + if (alterTables.isNotEmpty) { + await writeFile(dir, 'alter_tables', alterTables); + } + + await patchViews(dir, diff); +} + +Future patchViews(Directory dir, DatabaseSchemaDiff diff) async { + var toDrop = {...diff.views.removed, ...diff.views.modified.prev}; + var toAdd = {...diff.views.added, ...diff.views.modified.newly}; + + String? nodePath(ViewNode node, [Set visited = const {}]) { + if (visited.contains(node)) return node.view.name; + for (var child in node.children) { + var s = nodePath(child, {...visited, node}); + if (s != null) { + return '${node.view.name} -> $s'; + } + } + return null; + } + + var currViewNodes = ViewSchema.buildGraph(diff.existingSchema.views.values.toSet()); + + Iterable getParents(ViewNode n) => [n, ...n.parents.expand(getParents)]; + var toDropNodes = currViewNodes.where((n) => toDrop.contains(n.view)).expand(getParents).toSet(); + var toDropGraph = toDropNodes.where((n) => n.parents.isEmpty).toSet(); + + if (toDropGraph.isNotEmpty) { + var dropViews = ''; + + while (toDropGraph.isNotEmpty) { + var node = toDropGraph.first; + toDropGraph.remove(node); + toDropNodes.remove(node); + + if (!toDrop.contains(node.view)) { + toAdd.add(node.view); + } + + if (dropViews.isNotEmpty) { + dropViews += '\n\n'; + } + dropViews += 'DROP VIEW ${node.view.name};'; + + for (var child in node.children) { + child.parents.remove(node); + if (child.parents.isEmpty) { + toDropGraph.add(child); + } + } + } + + await writeFile(dir, 'drop_views', dropViews); + } + + if (toDropNodes.isNotEmpty) { + print('Error: Cyclic dependencies in dropped table views found: ${nodePath(toDropNodes.first)}'); + throw Exception(); + } + + await removeUnused(dir, diff); + + var toAddNodes = ViewSchema.buildGraph(toAdd); + var toAddGraph = toAddNodes.where((n) => n.children.isEmpty).toSet(); + + if (toAddGraph.isNotEmpty) { + var createViews = ''; + + while (toAddGraph.isNotEmpty) { + var node = toAddGraph.first; + toAddGraph.remove(node); + toAddNodes.remove(node); + + if (createViews.isNotEmpty) { + createViews += '\n\n'; + } + createViews += 'CREATE VIEW ${node.view.name} AS \n${node.view.definition};'; + + for (var parent in node.parents) { + parent.children.remove(node); + if (parent.children.isEmpty) { + toAddGraph.add(parent); + } + } + } + + await writeFile(dir, 'create_views', createViews); + } + + if (toAddNodes.isNotEmpty) { + print('Error: Cyclic dependencies in added table views found: ${nodePath(toAddNodes.first)}'); + throw Exception(); + } +} + +Future removeUnused(Directory dir, DatabaseSchemaDiff diff) async { + if (diff.tables.removed.isNotEmpty) { + var dropTables = ''; + + for (var table in diff.tables.removed) { + if (dropTables.isNotEmpty) { + dropTables += '\n\n'; + } + dropTables += 'DROP TABLE "${table.name}" CASCADE;'; + } + + await writeFile(dir, 'drop_tables', dropTables); + } +} diff --git a/bin/src/patcher.dart b/lib/src/cli/patcher.dart similarity index 72% rename from bin/src/patcher.dart rename to lib/src/cli/patcher.dart index 9dd9bb0..59aff35 100644 --- a/bin/src/patcher.dart +++ b/lib/src/cli/patcher.dart @@ -1,4 +1,4 @@ -import 'package:stormberry/stormberry.dart'; +import '../../stormberry.dart'; import 'differentiator.dart'; import 'schema.dart'; @@ -13,12 +13,6 @@ Future patchSchema(Database db, DatabaseSchemaDiff diff) async { } for (var table in diff.tables.modified) { - for (var trigger in table.triggers.removed) { - await db.query(''' - DROP TRIGGER "${trigger.name}" ON "${table.name}" - '''); - } - for (var index in table.indexes.removed) { await db.query('DROP INDEX "__${index.name}"'); } @@ -39,7 +33,7 @@ Future patchSchema(Database db, DatabaseSchemaDiff diff) async { }), ...table.columns.modified.expand((c) sync* { if (c.prev.type != 'serial' && c.newly.type == 'serial') { - yield 'ALTER COLUMN \"${c.prev.name}\" SET DATA TYPE int8 USING ${c.newly.name}::int8'; + yield 'ALTER COLUMN "${c.prev.name}" SET DATA TYPE int8 USING ${c.newly.name}::int8'; yield "ALTER COLUMN \"${c.prev.name}\" SET DEFAULT nextval('${table.name}_${c.newly.name}_seq')"; } else { var update = c.prev.type != c.newly.type @@ -104,33 +98,13 @@ Future patchSchema(Database db, DatabaseSchemaDiff diff) async { } } - await _createArrayKeysCheckFunction(db); - for (var table in diff.tables.modified) { - for (var trigger in table.triggers.added) { - await db.query(""" - CREATE TRIGGER "${trigger.name}" - AFTER DELETE OR UPDATE OF "${trigger.column}" ON "${table.name}" - FOR EACH ROW - EXECUTE FUNCTION ${trigger.function}(${trigger.args.map((a) => "'$a'").join(", ")}); - """); - } - for (var index in table.indexes.added) { await db.query('CREATE ${index.statement(table.name)}'); } } for (var table in diff.tables.added) { - for (var trigger in table.triggers) { - await db.query(""" - CREATE TRIGGER "${trigger.name}" - AFTER DELETE OR UPDATE OF "${trigger.column}" ON "${table.name}" - FOR EACH ROW - EXECUTE FUNCTION ${trigger.function}(${trigger.args.map((a) => "'$a'").join(", ")}); - """); - } - for (var index in table.indexes) { await db.query('CREATE ${index.statement(table.name)}'); } @@ -221,12 +195,6 @@ Future removeUnused(Database db, DatabaseSchemaDiff diff) async { } for (var table in diff.tables.removed) { - for (var trigger in table.triggers) { - await db.query(''' - DROP TRIGGER "${trigger.name}" ON "${table.name}" - '''); - } - for (var index in table.indexes) { await db.query('DROP INDEX "__${index.name}"'); } @@ -242,46 +210,4 @@ Future removeUnused(Database db, DatabaseSchemaDiff diff) async { for (var table in diff.tables.removed) { await db.query('DROP TABLE "${table.name}" CASCADE'); } -} - -Future _createArrayKeysCheckFunction(Database db) async { - var tempDebugPrint = db.debugPrint; - db.debugPrint = false; - await db.query(""" - CREATE OR REPLACE FUNCTION check_array_keys() - RETURNS TRIGGER - LANGUAGE plpgsql - AS \$function\$ - DECLARE - tableName TEXT; - columnName TEXT; - keyName TEXT; - BEGIN - tableName := TG_ARGV[0]; - columnName := TG_ARGV[1]; - keyName := TG_ARGV[2]; - - IF NEW IS NULL THEN - EXECUTE ' - UPDATE ' || quote_ident(tableName) || ' - SET ' || quote_ident(columnName) || ' = array_remove(' - || quote_ident(columnName) || ', \$1.' || quote_ident(keyName) - || ') - WHERE \$1.' || quote_ident(keyName) || ' = ANY (' || quote_ident(columnName) || ') - ' USING OLD; - ELSE - EXECUTE ' - UPDATE ' || quote_ident(tableName) || ' - SET ' || quote_ident(columnName) || ' = array_replace(' - || quote_ident(columnName) || ', \$1.' || quote_ident(keyName) || ', \$2.' || quote_ident(keyName) - || ') - WHERE \$1.' || quote_ident(keyName) || ' = ANY (' || quote_ident(columnName) || ') - ' USING OLD, NEW; - END IF; - - RETURN NULL; - END; - \$function\$ - """); - db.debugPrint = tempDebugPrint; -} +} \ No newline at end of file diff --git a/lib/src/cli/runner.dart b/lib/src/cli/runner.dart new file mode 100644 index 0000000..0db5c34 --- /dev/null +++ b/lib/src/cli/runner.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:args/command_runner.dart'; +import 'package:yaml/yaml.dart'; + +import '../../stormberry.dart'; +import 'differentiator.dart'; +import 'output.dart'; +import 'patcher.dart'; +import 'schema.dart'; + +class MigrateCommand extends Command { + MigrateCommand() { + argParser.addFlag( + 'dry-run', + negatable: false, + help: 'Returns exit code 1 if there are any pending migrations. ' + 'Does not apply any changes to the database.', + ); + argParser.addOption('db', help: 'Set the database name.'); + + argParser.addOption( + 'output', + abbr: 'o', + help: 'Specify an output directory. This will write all migrations into .sql ' + 'files instead of writing to the database.', + ); + + argParser.addFlag( + 'apply-changes', + negatable: false, + help: 'Applies all changes to the database without asking for confirmation.', + ); + } + + @override + String get description => 'Migrates the database to the generated schema.'; + + @override + String get name => 'migrate'; + + @override + Future run() async { + bool dryRun = argResults!['dry-run'] as bool; + String? dbName = argResults!['db'] as String?; + String? output = argResults!['output'] as String?; + bool applyChanges = argResults!['apply-changes'] as bool; + + var pubspecYaml = File('pubspec.yaml'); + + if (!pubspecYaml.existsSync()) { + print('Cannot find pubspec.yaml file in current directory.'); + exit(1); + } + + var packageName = loadYaml(await pubspecYaml.readAsString())['name'] as String; + + var buildYaml = File('build.yaml'); + + if (!buildYaml.existsSync()) { + print('Cannot find build.yaml file in current directory.'); + exit(1); + } + + var content = loadYaml(await buildYaml.readAsString()); + + List? generateTargets = (content['targets'] as YamlMap?) + ?.values + .map((t) => t['builders']?['stormberry']) + .where((b) => b != null) + .expand((b) => b['generate_for'] as List) + .map((d) => d as String) + .toList(); + + if (generateTargets == null || generateTargets.isEmpty) { + print('Cannot find stormberry generation targets in build.yaml. ' + 'Make sure you have the stormberry builder configured with at least one generation target.'); + exit(1); + } + + var schema = DatabaseSchema.empty(); + + for (var target in generateTargets) { + var schemaPath = target.replaceFirst('.dart', '.runner.g.dart'); + var file = File('.dart_tool/build/generated/$packageName/$schemaPath'); + if (!file.existsSync()) { + print('Could not run migration for target $target. Did you run the build script?'); + exit(1); + } + + var port = ReceivePort(); + await Isolate.spawnUri( + file.absolute.uri, + [], + port.sendPort, + packageConfig: Uri.parse('.dart_tool/package_config.json'), + //automaticPackageResolution: true, + ); + + var schemaMap = jsonDecode(await port.first as String); + var targetSchema = DatabaseSchema.fromMap(schemaMap as Map); + + schema = schema.mergeWith(targetSchema); + } + + if (dbName == null && Platform.environment['DB_NAME'] == null) { + stdout.write('Select a database to update: '); + dbName = stdin.readLineSync(encoding: Encoding.getByName('utf-8')!); + } + + var db = Database(database: dbName); + + await db.open(); + + print('Getting schema changes of ${db.name}'); + print('========================='); + + var diff = await getSchemaDiff(db, schema); + + if (diff.hasChanges) { + printDiff(diff); + print('========================='); + + if (dryRun) { + print('DATABASE SCHEME HAS CHANGES, EXITING'); + await db.close(); + exit(1); + } else { + if (output == null) { + await db.startTransaction(); + + String? answerApplyChanges; + if (!applyChanges) { + stdout.write('Do you want to apply these changes? (yes/no): '); + answerApplyChanges = stdin.readLineSync(encoding: Encoding.getByName('utf-8')!); + } + + if (applyChanges || answerApplyChanges == 'yes') { + print('Database schema changed, applying updates now:'); + + try { + db.debugPrint = true; + await patchSchema(db, diff); + } catch (_) { + db.cancelTransaction(); + } + } else { + db.cancelTransaction(); + } + + var updateWasSuccessFull = await db.finishTransaction(); + + print('========================'); + if (updateWasSuccessFull) { + print('---\nDATABASE UPDATE SUCCESSFUL'); + } else { + print('---\nALL CHANGES REVERTED, EXITING'); + } + + await db.close(); + exit(updateWasSuccessFull ? 0 : 1); + } else { + await db.close(); + var dir = Directory(output); + + String? answerApplyChanges; + if (!applyChanges) { + stdout.write('Do you want to write these migrations to ${dir.path}? (yes/no): '); + answerApplyChanges = stdin.readLineSync(encoding: Encoding.getByName('utf-8')!); + } + + if (applyChanges || answerApplyChanges == 'yes') { + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + + await outputSchema(dir, diff); + } + } + } + } else { + print('NO CHANGES, ALL DONE'); + } + } +} diff --git a/bin/src/schema.dart b/lib/src/cli/schema.dart similarity index 92% rename from bin/src/schema.dart rename to lib/src/cli/schema.dart index 9fcfbd2..66f3400 100644 --- a/bin/src/schema.dart +++ b/lib/src/cli/schema.dart @@ -70,14 +70,12 @@ class TableSchema { final String name; final Map columns; final List constraints; - final List triggers; final List indexes; const TableSchema( this.name, { this.columns = const {}, this.constraints = const [], - this.triggers = const [], this.indexes = const [], }); @@ -85,7 +83,6 @@ class TableSchema { name, columns: {...columns}, constraints: [...constraints], - triggers: [...triggers], indexes: [...indexes], ); } @@ -139,9 +136,7 @@ class PrimaryKeyConstraint extends TableConstraint { @override String toString() { - return ''' - PRIMARY KEY ( "$column" ) - '''; + return 'PRIMARY KEY ( "$column" )'; } @override @@ -167,11 +162,9 @@ class ForeignKeyConstraint extends TableConstraint { @override String toString() { - return ''' - FOREIGN KEY ( "$srcColumn" ) - REFERENCES $table ( "$column" ) - ON DELETE ${_ac(onDelete)} ON UPDATE ${_ac(onUpdate)} - '''; + return 'FOREIGN KEY ( "$srcColumn" ) ' + 'REFERENCES $table ( "$column" ) ' + 'ON DELETE ${_ac(onDelete)} ON UPDATE ${_ac(onUpdate)}'; } String _ac(ForeignKeyAction action) { @@ -203,9 +196,7 @@ class UniqueConstraint extends TableConstraint { @override String toString() { - return ''' - UNIQUE ( "$column" ) - '''; + return 'UNIQUE ( "$column" )'; } @override @@ -250,11 +241,9 @@ extension TableIndexParser on TableIndex { } String statement(String tableName) { - return """ - ${unique ? 'UNIQUE' : ''} INDEX "__$name" - ON "$tableName" USING ${algorithm.toString().split(".")[1]} ( $joinedColumns ) - ${condition != null ? 'WHERE $condition' : ''} - """; + return '${unique ? 'UNIQUE' : ''} INDEX "__$name" ' + 'ON "$tableName" USING ${algorithm.toString().split(".")[1]} ( $joinedColumns ) ' + '${condition != null ? 'WHERE $condition' : ''}'; } } diff --git a/lib/src/core/annotations.dart b/lib/src/core/annotations.dart index 50f03f1..9051d34 100644 --- a/lib/src/core/annotations.dart +++ b/lib/src/core/annotations.dart @@ -4,9 +4,16 @@ import 'database.dart'; class Model { final List views; final List indexes; + final String? tableName; + final dynamic insertRequestAnnotation; + final dynamic updateRequestAnnotation; + const Model({ this.views = const [], this.indexes = const [], + this.tableName, + this.insertRequestAnnotation, + this.updateRequestAnnotation, }); } @@ -14,7 +21,8 @@ class Model { class View { final String name; final List fields; - const View([this.name = '', this.fields = const []]); + final dynamic annotation; + const View([this.name = '', this.fields = const [], this.annotation]); } /// Used to define fields of [View]s @@ -55,13 +63,11 @@ abstract class ListTransformer extends Transformer { @override String transform(String column, String table) { var w = where(column, table); - return ''' - array_to_json(ARRAY (( - SELECT ${select(column, table) ?? '*'} - FROM jsonb_array_elements("$column".data) AS "$column" - ${w != null ? 'WHERE $w' : ''} - )) ) AS "$column" - '''; + return 'array_to_json(ARRAY ((\n' + ' SELECT ${select(column, table) ?? '*'}\n' + ' FROM jsonb_array_elements("$column".data) AS "$column"\n' + '${w != null ? ' WHERE $w\n' : ''}' + ')) ) AS "$column"'; } } diff --git a/lib/src/helpers/json_schema.dart b/lib/src/helpers/json_schema.dart index 2eaa8c4..de44e37 100644 --- a/lib/src/helpers/json_schema.dart +++ b/lib/src/helpers/json_schema.dart @@ -23,7 +23,7 @@ Map buildViewSchema(Map map) { map['table_name'] as String, map['primary_key_name'] as String?, (map['columns'] as List).map((c) => ViewColumnSchema.fromMap(c as Map)).toList(), - ).replaceAll(RegExp(r'\s+'), ' '); + ); var hash = sha1.convert(utf8.encode(definition)).toString(); @@ -130,6 +130,6 @@ String buildViewQuery(String tableName, String? primaryKeyName, List ', ${j.key}').join()}\n' - 'FROM "$tableName"\n' + 'FROM "$tableName"${joins.isNotEmpty ? '\n' : ''}' '${joins.map((j) => j.value).join('\n')}'; } diff --git a/lib/src/internals/model_registry.dart b/lib/src/internals/model_registry.dart index db79259..65c0e19 100644 --- a/lib/src/internals/model_registry.dart +++ b/lib/src/internals/model_registry.dart @@ -54,17 +54,15 @@ class ModelRegistry { ModelRegistry(Map converters) : converters = {..._baseConverters, ...converters}; T decode(dynamic value) { - if (converters[T] != null) { + if (value is T) { + return value; + } else if (converters[T] != null) { return converters[T]!.decode(value) as T; } else { - try { - return value as T; - } catch (_) { - throw ConverterException( - 'Cannot decode value $value of type ${value.runtimeType} to type $T: Unknown type.\n' - 'Did you forgot to include the class or register a custom type converter?', - ); - } + throw ConverterException( + 'Cannot decode value $value of type ${value.runtimeType} to type $T: Unknown type.\n' + 'Did you forgot to include the class or register a custom type converter?', + ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 7ae8ff0..6e5198c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,22 +1,25 @@ name: stormberry description: Access your postgres database effortlessly from dart code. -version: 0.7.0 +version: 0.8.0 repository: https://github.com/schultek/stormberry issue_tracker: https://github.com/schultek/stormberry/issues dependencies: - analyzer: ^3.3.1 - build: ^2.2.1 + analyzer: ">=4.2.0 <5.0.0" + args: ^2.3.1 + build: ^2.3.1 collection: ^1.16.0 - crypto: ^3.0.1 - dart_style: ^2.2.2 - postgres: ^2.4.3 - source_gen: ^1.2.1 - yaml: ^3.1.0 + crypto: ^3.0.2 + dart_style: ^2.2.4 + path: ^1.8.2 + postgres: ^2.5.1 + source_gen: ^1.2.3 + yaml: ^3.1.1 dev_dependencies: - lints: ^1.0.1 - test: ^1.21.1 + build_runner: ^2.2.0 + lints: ^2.0.0 + test: ^1.21.0 environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.16.0 <3.0.0' diff --git a/test/generation_test.dart b/test/generation_test.dart new file mode 100644 index 0000000..adf42d2 --- /dev/null +++ b/test/generation_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:stormberry/stormberry.dart'; +import 'package:test/test.dart'; + +@Model() +abstract class User { + @PrimaryKey() + String get id; + + String get name; + Account get account; +} + +@Model(views: [View('SuperSecret')]) +abstract class Account { + @PrimaryKey() + String get id; +} + +@Model(tableName: 'customTableName') +abstract class LegacyAccount { + @PrimaryKey() + String get id; +} + +void main() { + group('generation', () { + test('generates schemas', () async { + var proc = await Process.start( + 'dart', + 'run build_runner build --delete-conflicting-outputs'.split(' '), + workingDirectory: '.', + ); + + proc.stdout.listen((e) => stdout.add(e)); + + expect(await proc.exitCode, equals(0)); + + var schema = File('test/generation_test.schema.g.dart'); + + expect(schema.existsSync(), equals(true)); + }, timeout: Timeout(Duration(seconds: 60))); + + test('Test custom table name generated code', () async { + final schema = File('test/generation_test.schema.g.dart'); + final content = await schema.readAsString(); + expect(content.contains('String get tableAlias => \'customTableName\';'), equals(true)); + }); + }); +} diff --git a/test/generation_test.schema.g.dart b/test/generation_test.schema.g.dart new file mode 100644 index 0000000..682f6f6 --- /dev/null +++ b/test/generation_test.schema.g.dart @@ -0,0 +1,337 @@ +// ignore_for_file: prefer_relative_imports +import 'package:stormberry/internals.dart'; + +import 'generation_test.dart'; + +extension Repositories on Database { + UserRepository get users => UserRepository._(this); + AccountRepository get accounts => AccountRepository._(this); + LegacyAccountRepository get legacyAccounts => LegacyAccountRepository._(this); +} + +final registry = ModelRegistry({}); + +abstract class UserRepository + implements + ModelRepository, + ModelRepositoryInsert, + ModelRepositoryUpdate, + ModelRepositoryDelete { + factory UserRepository._(Database db) = _UserRepository; + + Future queryUser(String id); + Future> queryUsers([QueryParams? params]); +} + +class _UserRepository extends BaseRepository + with + RepositoryInsertMixin, + RepositoryUpdateMixin, + RepositoryDeleteMixin + implements UserRepository { + _UserRepository(Database db) : super(db: db); + + @override + Future queryUser(String id) { + return queryOne(id, UserQueryable()); + } + + @override + Future> queryUsers([QueryParams? params]) { + return queryMany(UserQueryable(), params); + } + + @override + Future insert(Database db, List requests) async { + if (requests.isEmpty) return; + + await db.query( + 'INSERT INTO "users" ( "id", "name", "account_id" )\n' + 'VALUES ${requests.map((r) => '( ${registry.encode(r.id)}, ${registry.encode(r.name)}, ${registry.encode(r.accountId)} )').join(', ')}\n' + 'ON CONFLICT ( "id" ) DO UPDATE SET "name" = EXCLUDED."name", "account_id" = EXCLUDED."account_id"', + ); + } + + @override + Future update(Database db, List requests) async { + if (requests.isEmpty) return; + await db.query( + 'UPDATE "users"\n' + 'SET "name" = COALESCE(UPDATED."name"::text, "users"."name"), "account_id" = COALESCE(UPDATED."account_id"::text, "users"."account_id")\n' + 'FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.id)}, ${registry.encode(r.name)}, ${registry.encode(r.accountId)} )').join(', ')} )\n' + 'AS UPDATED("id", "name", "account_id")\n' + 'WHERE "users"."id" = UPDATED."id"', + ); + } + + @override + Future delete(Database db, List keys) async { + if (keys.isEmpty) return; + await db.query( + 'DELETE FROM "users"\n' + 'WHERE "users"."id" IN ( ${keys.map((k) => registry.encode(k)).join(',')} )', + ); + } +} + +abstract class AccountRepository + implements + ModelRepository, + ModelRepositoryInsert, + ModelRepositoryUpdate, + ModelRepositoryDelete { + factory AccountRepository._(Database db) = _AccountRepository; + + Future querySuperSecretView(String id); + Future> querySuperSecretViews([QueryParams? params]); +} + +class _AccountRepository extends BaseRepository + with + RepositoryInsertMixin, + RepositoryUpdateMixin, + RepositoryDeleteMixin + implements AccountRepository { + _AccountRepository(Database db) : super(db: db); + + @override + Future querySuperSecretView(String id) { + return queryOne(id, SuperSecretAccountViewQueryable()); + } + + @override + Future> querySuperSecretViews([QueryParams? params]) { + return queryMany(SuperSecretAccountViewQueryable(), params); + } + + @override + Future insert(Database db, List requests) async { + if (requests.isEmpty) return; + + await db.query( + 'INSERT INTO "accounts" ( "id" )\n' + 'VALUES ${requests.map((r) => '( ${registry.encode(r.id)} )').join(', ')}\n' + 'ON CONFLICT ( "id" ) DO UPDATE SET ', + ); + } + + @override + Future update(Database db, List requests) async { + if (requests.isEmpty) return; + await db.query( + 'UPDATE "accounts"\n' + 'SET \n' + 'FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.id)} )').join(', ')} )\n' + 'AS UPDATED("id")\n' + 'WHERE "accounts"."id" = UPDATED."id"', + ); + } + + @override + Future delete(Database db, List keys) async { + if (keys.isEmpty) return; + await db.query( + 'DELETE FROM "accounts"\n' + 'WHERE "accounts"."id" IN ( ${keys.map((k) => registry.encode(k)).join(',')} )', + ); + } +} + +abstract class LegacyAccountRepository + implements + ModelRepository, + ModelRepositoryInsert, + ModelRepositoryUpdate, + ModelRepositoryDelete { + factory LegacyAccountRepository._(Database db) = _LegacyAccountRepository; + + Future queryLegacyAccount(String id); + Future> queryLegacyAccounts([QueryParams? params]); +} + +class _LegacyAccountRepository extends BaseRepository + with + RepositoryInsertMixin, + RepositoryUpdateMixin, + RepositoryDeleteMixin + implements LegacyAccountRepository { + _LegacyAccountRepository(Database db) : super(db: db); + + @override + Future queryLegacyAccount(String id) { + return queryOne(id, LegacyAccountQueryable()); + } + + @override + Future> queryLegacyAccounts([QueryParams? params]) { + return queryMany(LegacyAccountQueryable(), params); + } + + @override + Future insert(Database db, List requests) async { + if (requests.isEmpty) return; + + await db.query( + 'INSERT INTO "customTableName" ( "id" )\n' + 'VALUES ${requests.map((r) => '( ${registry.encode(r.id)} )').join(', ')}\n' + 'ON CONFLICT ( "id" ) DO UPDATE SET ', + ); + } + + @override + Future update(Database db, List requests) async { + if (requests.isEmpty) return; + await db.query( + 'UPDATE "customTableName"\n' + 'SET \n' + 'FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.id)} )').join(', ')} )\n' + 'AS UPDATED("id")\n' + 'WHERE "customTableName"."id" = UPDATED."id"', + ); + } + + @override + Future delete(Database db, List keys) async { + if (keys.isEmpty) return; + await db.query( + 'DELETE FROM "customTableName"\n' + 'WHERE "customTableName"."id" IN ( ${keys.map((k) => registry.encode(k)).join(',')} )', + ); + } +} + +class UserInsertRequest { + UserInsertRequest({required this.id, required this.name, required this.accountId}); + String id; + String name; + String accountId; +} + +class AccountInsertRequest { + AccountInsertRequest({required this.id}); + String id; +} + +class LegacyAccountInsertRequest { + LegacyAccountInsertRequest({required this.id}); + String id; +} + +class UserUpdateRequest { + UserUpdateRequest({required this.id, this.name, this.accountId}); + String id; + String? name; + String? accountId; +} + +class AccountUpdateRequest { + AccountUpdateRequest({required this.id}); + String id; +} + +class LegacyAccountUpdateRequest { + LegacyAccountUpdateRequest({required this.id}); + String id; +} + +class UserQueryable extends KeyedViewQueryable { + @override + String get keyName => 'id'; + + @override + String encodeKey(String key) => registry.encode(key); + + @override + String get tableName => 'users_view'; + + @override + String get tableAlias => 'users'; + + @override + User decode(TypedMap map) => UserView( + id: map.get('id', registry.decode), + name: map.get('name', registry.decode), + account: map.get('account', AccountQueryable().decoder)); +} + +class UserView implements User { + UserView({required this.id, required this.name, required this.account}); + + @override + final String id; + @override + final String name; + @override + final Account account; +} + +class SuperSecretAccountViewQueryable extends KeyedViewQueryable { + @override + String get keyName => 'id'; + + @override + String encodeKey(String key) => registry.encode(key); + + @override + String get tableName => 'super_secret_accounts_view'; + + @override + String get tableAlias => 'accounts'; + + @override + SuperSecretAccountView decode(TypedMap map) => SuperSecretAccountView(id: map.get('id', registry.decode)); +} + +class SuperSecretAccountView { + SuperSecretAccountView({required this.id}); + + final String id; +} + +class AccountQueryable extends KeyedViewQueryable { + @override + String get keyName => 'id'; + + @override + String encodeKey(String key) => registry.encode(key); + + @override + String get tableName => 'accounts_view'; + + @override + String get tableAlias => 'accounts'; + + @override + Account decode(TypedMap map) => AccountView(id: map.get('id', registry.decode)); +} + +class AccountView implements Account { + AccountView({required this.id}); + + @override + final String id; +} + +class LegacyAccountQueryable extends KeyedViewQueryable { + @override + String get keyName => 'id'; + + @override + String encodeKey(String key) => registry.encode(key); + + @override + String get tableName => 'custom_table_name_view'; + + @override + String get tableAlias => 'customTableName'; + + @override + LegacyAccount decode(TypedMap map) => LegacyAccountView(id: map.get('id', registry.decode)); +} + +class LegacyAccountView implements LegacyAccount { + LegacyAccountView({required this.id}); + + @override + final String id; +} diff --git a/test/multi_schema_test.dart b/test/multi_schema_test.dart index e4988cf..faf011f 100644 --- a/test/multi_schema_test.dart +++ b/test/multi_schema_test.dart @@ -8,15 +8,15 @@ void main() { var proc = await Process.start( 'dart', 'run build_runner build --delete-conflicting-outputs'.split(' '), - workingDirectory: 'test/multi_schema', + workingDirectory: 'test/packages/multi_schema', ); proc.stdout.listen((e) => stdout.add(e)); expect(await proc.exitCode, equals(0)); - var schemaA = File('test/multi_schema/lib/modelsA.schema.g.dart'); - var schemaB = File('test/multi_schema/lib/modelsB.schema.g.dart'); + var schemaA = File('test/packages/multi_schema/lib/modelsA.schema.g.dart'); + var schemaB = File('test/packages/multi_schema/lib/modelsB.schema.g.dart'); expect(schemaA.existsSync(), equals(true)); expect(schemaB.existsSync(), equals(true)); @@ -25,8 +25,8 @@ void main() { test('Migrating schemas', () async { var proc = await Process.start( 'dart', - 'run stormberry --apply-changes'.split(' '), - workingDirectory: 'test/multi_schema', + 'run stormberry migrate --apply-changes'.split(' '), + workingDirectory: 'test/packages/multi_schema', environment: { 'DB_HOST': 'localhost', 'DB_PORT': '2222', diff --git a/test/multi_schema/build.yaml b/test/packages/multi_schema/build.yaml similarity index 100% rename from test/multi_schema/build.yaml rename to test/packages/multi_schema/build.yaml diff --git a/test/multi_schema/lib/main.dart b/test/packages/multi_schema/lib/main.dart similarity index 100% rename from test/multi_schema/lib/main.dart rename to test/packages/multi_schema/lib/main.dart diff --git a/test/multi_schema/lib/modelsA.dart b/test/packages/multi_schema/lib/modelsA.dart similarity index 100% rename from test/multi_schema/lib/modelsA.dart rename to test/packages/multi_schema/lib/modelsA.dart diff --git a/test/multi_schema/lib/modelsA.schema.g.dart b/test/packages/multi_schema/lib/modelsA.schema.g.dart similarity index 57% rename from test/multi_schema/lib/modelsA.schema.g.dart rename to test/packages/multi_schema/lib/modelsA.schema.g.dart index 4f2ab3d..5ce47b5 100644 --- a/test/multi_schema/lib/modelsA.schema.g.dart +++ b/test/packages/multi_schema/lib/modelsA.schema.g.dart @@ -1,6 +1,7 @@ // ignore_for_file: prefer_relative_imports import 'package:stormberry/internals.dart'; -import 'package:multi_schema_test/modelsA.dart'; + +import 'modelsA.dart'; extension Repositories on Database { ModelARepository get modelAs => ModelARepository._(this); @@ -12,7 +13,7 @@ abstract class ModelARepository implements ModelRepository, ModelRepositoryInsert, ModelRepositoryUpdate { factory ModelARepository._(Database db) = _ModelARepository; - Future> queryViewaViews([QueryParams? params]); + Future> queryViewAViews([QueryParams? params]); } class _ModelARepository extends BaseRepository @@ -21,30 +22,30 @@ class _ModelARepository extends BaseRepository _ModelARepository(Database db) : super(db: db); @override - Future> queryViewaViews([QueryParams? params]) { - return queryMany(ViewaModelAViewQueryable(), params); + Future> queryViewAViews([QueryParams? params]) { + return queryMany(ViewAModelAViewQueryable(), params); } @override Future insert(Database db, List requests) async { if (requests.isEmpty) return; - await db.query(""" - INSERT INTO "model_as" ( "data" ) - VALUES ${requests.map((r) => '( ${registry.encode(r.data)} )').join(', ')} - - """); + + await db.query( + 'INSERT INTO "model_as" ( "data" )\n' + 'VALUES ${requests.map((r) => '( ${registry.encode(r.data)} )').join(', ')}\n', + ); } @override Future update(Database db, List requests) async { if (requests.isEmpty) return; - await db.query(""" - UPDATE "model_as" - SET "data" = COALESCE(UPDATED."data"::text, "model_as"."data") - FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.data)} )').join(', ')} ) - AS UPDATED("data") - WHERE - """); + await db.query( + 'UPDATE "model_as"\n' + 'SET "data" = COALESCE(UPDATED."data"::text, "model_as"."data")\n' + 'FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.data)} )').join(', ')} )\n' + 'AS UPDATED("data")\n' + 'WHERE ', + ); } } @@ -58,17 +59,17 @@ class ModelAUpdateRequest { String? data; } -class ViewaModelAViewQueryable extends ViewQueryable { +class ViewAModelAViewQueryable extends ViewQueryable { @override - String get tableName => 'viewa_model_as_view'; + String get tableName => 'view_a_model_as_view'; @override String get tableAlias => 'model_as'; @override - ViewaModelAView decode(TypedMap map) => ViewaModelAView(); + ViewAModelAView decode(TypedMap map) => ViewAModelAView(); } -class ViewaModelAView { - ViewaModelAView(); +class ViewAModelAView { + ViewAModelAView(); } diff --git a/test/multi_schema/lib/modelsB.dart b/test/packages/multi_schema/lib/modelsB.dart similarity index 100% rename from test/multi_schema/lib/modelsB.dart rename to test/packages/multi_schema/lib/modelsB.dart diff --git a/test/multi_schema/lib/modelsB.schema.g.dart b/test/packages/multi_schema/lib/modelsB.schema.g.dart similarity index 57% rename from test/multi_schema/lib/modelsB.schema.g.dart rename to test/packages/multi_schema/lib/modelsB.schema.g.dart index 553195a..21f47f5 100644 --- a/test/multi_schema/lib/modelsB.schema.g.dart +++ b/test/packages/multi_schema/lib/modelsB.schema.g.dart @@ -1,6 +1,7 @@ // ignore_for_file: prefer_relative_imports import 'package:stormberry/internals.dart'; -import 'package:multi_schema_test/modelsB.dart'; + +import 'modelsB.dart'; extension Repositories on Database { ModelBRepository get modelBs => ModelBRepository._(this); @@ -12,7 +13,7 @@ abstract class ModelBRepository implements ModelRepository, ModelRepositoryInsert, ModelRepositoryUpdate { factory ModelBRepository._(Database db) = _ModelBRepository; - Future> queryViewbViews([QueryParams? params]); + Future> queryViewBViews([QueryParams? params]); } class _ModelBRepository extends BaseRepository @@ -21,30 +22,30 @@ class _ModelBRepository extends BaseRepository _ModelBRepository(Database db) : super(db: db); @override - Future> queryViewbViews([QueryParams? params]) { - return queryMany(ViewbModelBViewQueryable(), params); + Future> queryViewBViews([QueryParams? params]) { + return queryMany(ViewBModelBViewQueryable(), params); } @override Future insert(Database db, List requests) async { if (requests.isEmpty) return; - await db.query(""" - INSERT INTO "model_bs" ( "data" ) - VALUES ${requests.map((r) => '( ${registry.encode(r.data)} )').join(', ')} - - """); + + await db.query( + 'INSERT INTO "model_bs" ( "data" )\n' + 'VALUES ${requests.map((r) => '( ${registry.encode(r.data)} )').join(', ')}\n', + ); } @override Future update(Database db, List requests) async { if (requests.isEmpty) return; - await db.query(""" - UPDATE "model_bs" - SET "data" = COALESCE(UPDATED."data"::text, "model_bs"."data") - FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.data)} )').join(', ')} ) - AS UPDATED("data") - WHERE - """); + await db.query( + 'UPDATE "model_bs"\n' + 'SET "data" = COALESCE(UPDATED."data"::text, "model_bs"."data")\n' + 'FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.data)} )').join(', ')} )\n' + 'AS UPDATED("data")\n' + 'WHERE ', + ); } } @@ -58,17 +59,17 @@ class ModelBUpdateRequest { String? data; } -class ViewbModelBViewQueryable extends ViewQueryable { +class ViewBModelBViewQueryable extends ViewQueryable { @override - String get tableName => 'viewb_model_bs_view'; + String get tableName => 'view_b_model_bs_view'; @override String get tableAlias => 'model_bs'; @override - ViewbModelBView decode(TypedMap map) => ViewbModelBView(); + ViewBModelBView decode(TypedMap map) => ViewBModelBView(); } -class ViewbModelBView { - ViewbModelBView(); +class ViewBModelBView { + ViewBModelBView(); } diff --git a/test/multi_schema/pubspec.yaml b/test/packages/multi_schema/pubspec.yaml similarity index 65% rename from test/multi_schema/pubspec.yaml rename to test/packages/multi_schema/pubspec.yaml index 474baa7..df95241 100644 --- a/test/multi_schema/pubspec.yaml +++ b/test/packages/multi_schema/pubspec.yaml @@ -5,8 +5,8 @@ environment: dependencies: stormberry: - path: ../../ + path: ../../../ dev_dependencies: - build_runner: ^2.1.7 - lints: ^1.0.1 \ No newline at end of file + build_runner: ^2.2.1 + lints: ^2.0.0 \ No newline at end of file diff --git a/test/packages/serialization/build.yaml b/test/packages/serialization/build.yaml new file mode 100644 index 0000000..b072068 --- /dev/null +++ b/test/packages/serialization/build.yaml @@ -0,0 +1,13 @@ +targets: + $default: + builders: + stormberry: + generate_for: + - lib/models.dart + serialization: + dependencies: + - serialization_test + builders: + dart_mappable_builder: + generate_for: + - lib/models.dart \ No newline at end of file diff --git a/test/packages/serialization/lib/main.dart b/test/packages/serialization/lib/main.dart new file mode 100644 index 0000000..d57b97a --- /dev/null +++ b/test/packages/serialization/lib/main.dart @@ -0,0 +1,15 @@ +import 'models.dart'; + +Future main() async { + var user = DefaultUserView(id: 'abc', name: 'Tom', securityNumber: '12345'); + + print(user.toJson()); + print(Mapper.asString(user.copyWith(name: 'Alex'))); + + var company = DefaultCompanyView(id: '01', member: PublicUserView(id: 'def', name: 'Susan')); + + print(company.toJson()); + + var request = UserUpdateRequest(id: 'abc', securityNumber: '007'); + print(request.toJson()); +} diff --git a/test/packages/serialization/lib/models.dart b/test/packages/serialization/lib/models.dart new file mode 100644 index 0000000..9b8dca8 --- /dev/null +++ b/test/packages/serialization/lib/models.dart @@ -0,0 +1,32 @@ +import 'package:dart_mappable/dart_mappable.dart'; +import 'package:stormberry/stormberry.dart'; + +export 'package:dart_mappable/dart_mappable.dart'; +export 'models.schema.g.dart'; +export 'models.mapper.g.dart'; + +@Model( + views: [ + View('Default', [], MappableClass()), + View('Public', [Field.hidden('securityNumber')], MappableClass()), + ], + insertRequestAnnotation: MappableClass(), + updateRequestAnnotation: MappableClass(), +) +abstract class User { + @PrimaryKey() + String get id; + + String get name; + String get securityNumber; +} + +@Model(views: [ + View('Default', [Field.view('member', as: 'Public')], MappableClass()), +]) +abstract class Company { + @PrimaryKey() + String get id; + + User get member; +} diff --git a/test/packages/serialization/lib/models.mapper.g.dart b/test/packages/serialization/lib/models.mapper.g.dart new file mode 100644 index 0000000..d89f90a --- /dev/null +++ b/test/packages/serialization/lib/models.mapper.g.dart @@ -0,0 +1,272 @@ +import 'package:dart_mappable/dart_mappable.dart'; +import 'package:dart_mappable/internals.dart'; + +import 'models.schema.g.dart'; + + +// === ALL STATICALLY REGISTERED MAPPERS === + +var _mappers = { + // class mappers + UserInsertRequestMapper._(), + UserUpdateRequestMapper._(), + DefaultUserViewMapper._(), + PublicUserViewMapper._(), + DefaultCompanyViewMapper._(), + // enum mappers + // custom mappers +}; + + +// === GENERATED CLASS MAPPERS AND EXTENSIONS === + +class UserInsertRequestMapper extends BaseMapper { + UserInsertRequestMapper._(); + + @override Function get decoder => decode; + UserInsertRequest decode(dynamic v) => checked(v, (Map map) => fromMap(map)); + UserInsertRequest fromMap(Map map) => UserInsertRequest(id: Mapper.i.$get(map, 'id'), name: Mapper.i.$get(map, 'name'), securityNumber: Mapper.i.$get(map, 'securityNumber')); + + @override Function get encoder => (UserInsertRequest v) => encode(v); + dynamic encode(UserInsertRequest v) => toMap(v); + Map toMap(UserInsertRequest u) => {'id': Mapper.i.$enc(u.id, 'id'), 'name': Mapper.i.$enc(u.name, 'name'), 'securityNumber': Mapper.i.$enc(u.securityNumber, 'securityNumber')}; + + @override String stringify(UserInsertRequest self) => 'UserInsertRequest(id: ${Mapper.asString(self.id)}, name: ${Mapper.asString(self.name)}, securityNumber: ${Mapper.asString(self.securityNumber)})'; + @override int hash(UserInsertRequest self) => Mapper.hash(self.id) ^ Mapper.hash(self.name) ^ Mapper.hash(self.securityNumber); + @override bool equals(UserInsertRequest self, UserInsertRequest other) => Mapper.isEqual(self.id, other.id) && Mapper.isEqual(self.name, other.name) && Mapper.isEqual(self.securityNumber, other.securityNumber); + + @override Function get typeFactory => (f) => f(); +} + +extension UserInsertRequestMapperExtension on UserInsertRequest { + String toJson() => Mapper.toJson(this); + Map toMap() => Mapper.toMap(this); + UserInsertRequestCopyWith get copyWith => UserInsertRequestCopyWith(this, $identity); +} + +abstract class UserInsertRequestCopyWith<$R> { + factory UserInsertRequestCopyWith(UserInsertRequest value, Then then) = _UserInsertRequestCopyWithImpl<$R>; + $R call({String? id, String? name, String? securityNumber}); + $R apply(UserInsertRequest Function(UserInsertRequest) transform); +} + +class _UserInsertRequestCopyWithImpl<$R> extends BaseCopyWith implements UserInsertRequestCopyWith<$R> { + _UserInsertRequestCopyWithImpl(UserInsertRequest value, Then then) : super(value, then); + + @override $R call({String? id, String? name, String? securityNumber}) => $then(UserInsertRequest(id: id ?? $value.id, name: name ?? $value.name, securityNumber: securityNumber ?? $value.securityNumber)); +} + +class UserUpdateRequestMapper extends BaseMapper { + UserUpdateRequestMapper._(); + + @override Function get decoder => decode; + UserUpdateRequest decode(dynamic v) => checked(v, (Map map) => fromMap(map)); + UserUpdateRequest fromMap(Map map) => UserUpdateRequest(id: Mapper.i.$get(map, 'id'), name: Mapper.i.$getOpt(map, 'name'), securityNumber: Mapper.i.$getOpt(map, 'securityNumber')); + + @override Function get encoder => (UserUpdateRequest v) => encode(v); + dynamic encode(UserUpdateRequest v) => toMap(v); + Map toMap(UserUpdateRequest u) => {'id': Mapper.i.$enc(u.id, 'id'), 'name': Mapper.i.$enc(u.name, 'name'), 'securityNumber': Mapper.i.$enc(u.securityNumber, 'securityNumber')}; + + @override String stringify(UserUpdateRequest self) => 'UserUpdateRequest(id: ${Mapper.asString(self.id)}, name: ${Mapper.asString(self.name)}, securityNumber: ${Mapper.asString(self.securityNumber)})'; + @override int hash(UserUpdateRequest self) => Mapper.hash(self.id) ^ Mapper.hash(self.name) ^ Mapper.hash(self.securityNumber); + @override bool equals(UserUpdateRequest self, UserUpdateRequest other) => Mapper.isEqual(self.id, other.id) && Mapper.isEqual(self.name, other.name) && Mapper.isEqual(self.securityNumber, other.securityNumber); + + @override Function get typeFactory => (f) => f(); +} + +extension UserUpdateRequestMapperExtension on UserUpdateRequest { + String toJson() => Mapper.toJson(this); + Map toMap() => Mapper.toMap(this); + UserUpdateRequestCopyWith get copyWith => UserUpdateRequestCopyWith(this, $identity); +} + +abstract class UserUpdateRequestCopyWith<$R> { + factory UserUpdateRequestCopyWith(UserUpdateRequest value, Then then) = _UserUpdateRequestCopyWithImpl<$R>; + $R call({String? id, String? name, String? securityNumber}); + $R apply(UserUpdateRequest Function(UserUpdateRequest) transform); +} + +class _UserUpdateRequestCopyWithImpl<$R> extends BaseCopyWith implements UserUpdateRequestCopyWith<$R> { + _UserUpdateRequestCopyWithImpl(UserUpdateRequest value, Then then) : super(value, then); + + @override $R call({String? id, Object? name = $none, Object? securityNumber = $none}) => $then(UserUpdateRequest(id: id ?? $value.id, name: or(name, $value.name), securityNumber: or(securityNumber, $value.securityNumber))); +} + +class DefaultUserViewMapper extends BaseMapper { + DefaultUserViewMapper._(); + + @override Function get decoder => decode; + DefaultUserView decode(dynamic v) => checked(v, (Map map) => fromMap(map)); + DefaultUserView fromMap(Map map) => DefaultUserView(id: Mapper.i.$get(map, 'id'), name: Mapper.i.$get(map, 'name'), securityNumber: Mapper.i.$get(map, 'securityNumber')); + + @override Function get encoder => (DefaultUserView v) => encode(v); + dynamic encode(DefaultUserView v) => toMap(v); + Map toMap(DefaultUserView d) => {'id': Mapper.i.$enc(d.id, 'id'), 'name': Mapper.i.$enc(d.name, 'name'), 'securityNumber': Mapper.i.$enc(d.securityNumber, 'securityNumber')}; + + @override String stringify(DefaultUserView self) => 'DefaultUserView(id: ${Mapper.asString(self.id)}, name: ${Mapper.asString(self.name)}, securityNumber: ${Mapper.asString(self.securityNumber)})'; + @override int hash(DefaultUserView self) => Mapper.hash(self.id) ^ Mapper.hash(self.name) ^ Mapper.hash(self.securityNumber); + @override bool equals(DefaultUserView self, DefaultUserView other) => Mapper.isEqual(self.id, other.id) && Mapper.isEqual(self.name, other.name) && Mapper.isEqual(self.securityNumber, other.securityNumber); + + @override Function get typeFactory => (f) => f(); +} + +extension DefaultUserViewMapperExtension on DefaultUserView { + String toJson() => Mapper.toJson(this); + Map toMap() => Mapper.toMap(this); + DefaultUserViewCopyWith get copyWith => DefaultUserViewCopyWith(this, $identity); +} + +abstract class DefaultUserViewCopyWith<$R> { + factory DefaultUserViewCopyWith(DefaultUserView value, Then then) = _DefaultUserViewCopyWithImpl<$R>; + $R call({String? id, String? name, String? securityNumber}); + $R apply(DefaultUserView Function(DefaultUserView) transform); +} + +class _DefaultUserViewCopyWithImpl<$R> extends BaseCopyWith implements DefaultUserViewCopyWith<$R> { + _DefaultUserViewCopyWithImpl(DefaultUserView value, Then then) : super(value, then); + + @override $R call({String? id, String? name, String? securityNumber}) => $then(DefaultUserView(id: id ?? $value.id, name: name ?? $value.name, securityNumber: securityNumber ?? $value.securityNumber)); +} + +class PublicUserViewMapper extends BaseMapper { + PublicUserViewMapper._(); + + @override Function get decoder => decode; + PublicUserView decode(dynamic v) => checked(v, (Map map) => fromMap(map)); + PublicUserView fromMap(Map map) => PublicUserView(id: Mapper.i.$get(map, 'id'), name: Mapper.i.$get(map, 'name')); + + @override Function get encoder => (PublicUserView v) => encode(v); + dynamic encode(PublicUserView v) => toMap(v); + Map toMap(PublicUserView p) => {'id': Mapper.i.$enc(p.id, 'id'), 'name': Mapper.i.$enc(p.name, 'name')}; + + @override String stringify(PublicUserView self) => 'PublicUserView(id: ${Mapper.asString(self.id)}, name: ${Mapper.asString(self.name)})'; + @override int hash(PublicUserView self) => Mapper.hash(self.id) ^ Mapper.hash(self.name); + @override bool equals(PublicUserView self, PublicUserView other) => Mapper.isEqual(self.id, other.id) && Mapper.isEqual(self.name, other.name); + + @override Function get typeFactory => (f) => f(); +} + +extension PublicUserViewMapperExtension on PublicUserView { + String toJson() => Mapper.toJson(this); + Map toMap() => Mapper.toMap(this); + PublicUserViewCopyWith get copyWith => PublicUserViewCopyWith(this, $identity); +} + +abstract class PublicUserViewCopyWith<$R> { + factory PublicUserViewCopyWith(PublicUserView value, Then then) = _PublicUserViewCopyWithImpl<$R>; + $R call({String? id, String? name}); + $R apply(PublicUserView Function(PublicUserView) transform); +} + +class _PublicUserViewCopyWithImpl<$R> extends BaseCopyWith implements PublicUserViewCopyWith<$R> { + _PublicUserViewCopyWithImpl(PublicUserView value, Then then) : super(value, then); + + @override $R call({String? id, String? name}) => $then(PublicUserView(id: id ?? $value.id, name: name ?? $value.name)); +} + +class DefaultCompanyViewMapper extends BaseMapper { + DefaultCompanyViewMapper._(); + + @override Function get decoder => decode; + DefaultCompanyView decode(dynamic v) => checked(v, (Map map) => fromMap(map)); + DefaultCompanyView fromMap(Map map) => DefaultCompanyView(id: Mapper.i.$get(map, 'id'), member: Mapper.i.$get(map, 'member')); + + @override Function get encoder => (DefaultCompanyView v) => encode(v); + dynamic encode(DefaultCompanyView v) => toMap(v); + Map toMap(DefaultCompanyView d) => {'id': Mapper.i.$enc(d.id, 'id'), 'member': Mapper.i.$enc(d.member, 'member')}; + + @override String stringify(DefaultCompanyView self) => 'DefaultCompanyView(id: ${Mapper.asString(self.id)}, member: ${Mapper.asString(self.member)})'; + @override int hash(DefaultCompanyView self) => Mapper.hash(self.id) ^ Mapper.hash(self.member); + @override bool equals(DefaultCompanyView self, DefaultCompanyView other) => Mapper.isEqual(self.id, other.id) && Mapper.isEqual(self.member, other.member); + + @override Function get typeFactory => (f) => f(); +} + +extension DefaultCompanyViewMapperExtension on DefaultCompanyView { + String toJson() => Mapper.toJson(this); + Map toMap() => Mapper.toMap(this); + DefaultCompanyViewCopyWith get copyWith => DefaultCompanyViewCopyWith(this, $identity); +} + +abstract class DefaultCompanyViewCopyWith<$R> { + factory DefaultCompanyViewCopyWith(DefaultCompanyView value, Then then) = _DefaultCompanyViewCopyWithImpl<$R>; + PublicUserViewCopyWith<$R> get member; + $R call({String? id, PublicUserView? member}); + $R apply(DefaultCompanyView Function(DefaultCompanyView) transform); +} + +class _DefaultCompanyViewCopyWithImpl<$R> extends BaseCopyWith implements DefaultCompanyViewCopyWith<$R> { + _DefaultCompanyViewCopyWithImpl(DefaultCompanyView value, Then then) : super(value, then); + + @override PublicUserViewCopyWith<$R> get member => PublicUserViewCopyWith($value.member, (v) => call(member: v)); + @override $R call({String? id, PublicUserView? member}) => $then(DefaultCompanyView(id: id ?? $value.id, member: member ?? $value.member)); +} + + +// === GENERATED ENUM MAPPERS AND EXTENSIONS === + + + + +// === GENERATED UTILITY CODE === + +class Mapper { + Mapper._(); + + static MapperContainer i = MapperContainer(_mappers); + + static T fromValue(dynamic value) => i.fromValue(value); + static T fromMap(Map map) => i.fromMap(map); + static T fromIterable(Iterable iterable) => i.fromIterable(iterable); + static T fromJson(String json) => i.fromJson(json); + + static dynamic toValue(dynamic value) => i.toValue(value); + static Map toMap(dynamic object) => i.toMap(object); + static Iterable toIterable(dynamic object) => i.toIterable(object); + static String toJson(dynamic object) => i.toJson(object); + + static bool isEqual(dynamic value, Object? other) => i.isEqual(value, other); + static int hash(dynamic value) => i.hash(value); + static String asString(dynamic value) => i.asString(value); + + static void use(BaseMapper mapper) => i.use(mapper); + static BaseMapper? unuse() => i.unuse(); + static void useAll(List mappers) => i.useAll(mappers); + + static BaseMapper? get([Type? type]) => i.get(type); + static List getAll() => i.getAll(); +} + +mixin Mappable implements MappableMixin { + String toJson() => Mapper.toJson(this); + Map toMap() => Mapper.toMap(this); + + @override + String toString() { + return _guard(() => Mapper.asString(this), super.toString); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (runtimeType == other.runtimeType && + _guard(() => Mapper.isEqual(this, other), () => super == other)); + } + + @override + int get hashCode { + return _guard(() => Mapper.hash(this), () => super.hashCode); + } + + T _guard(T Function() fn, T Function() fallback) { + try { + return fn(); + } on MapperException catch (e) { + if (e.isUnsupportedOrUnallowed()) { + return fallback(); + } else { + rethrow; + } + } + } +} diff --git a/test/packages/serialization/lib/models.schema.g.dart b/test/packages/serialization/lib/models.schema.g.dart new file mode 100644 index 0000000..9ccaa35 --- /dev/null +++ b/test/packages/serialization/lib/models.schema.g.dart @@ -0,0 +1,258 @@ +// ignore_for_file: prefer_relative_imports +import 'package:stormberry/internals.dart'; + +import 'models.dart'; + +extension Repositories on Database { + UserRepository get users => UserRepository._(this); + CompanyRepository get companies => CompanyRepository._(this); +} + +final registry = ModelRegistry({}); + +abstract class UserRepository + implements + ModelRepository, + ModelRepositoryInsert, + ModelRepositoryUpdate, + ModelRepositoryDelete { + factory UserRepository._(Database db) = _UserRepository; + + Future queryDefaultView(String id); + Future> queryDefaultViews([QueryParams? params]); + Future queryPublicView(String id); + Future> queryPublicViews([QueryParams? params]); +} + +class _UserRepository extends BaseRepository + with + RepositoryInsertMixin, + RepositoryUpdateMixin, + RepositoryDeleteMixin + implements UserRepository { + _UserRepository(Database db) : super(db: db); + + @override + Future queryDefaultView(String id) { + return queryOne(id, DefaultUserViewQueryable()); + } + + @override + Future> queryDefaultViews([QueryParams? params]) { + return queryMany(DefaultUserViewQueryable(), params); + } + + @override + Future queryPublicView(String id) { + return queryOne(id, PublicUserViewQueryable()); + } + + @override + Future> queryPublicViews([QueryParams? params]) { + return queryMany(PublicUserViewQueryable(), params); + } + + @override + Future insert(Database db, List requests) async { + if (requests.isEmpty) return; + + await db.query( + 'INSERT INTO "users" ( "id", "name", "security_number" )\n' + 'VALUES ${requests.map((r) => '( ${registry.encode(r.id)}, ${registry.encode(r.name)}, ${registry.encode(r.securityNumber)} )').join(', ')}\n' + 'ON CONFLICT ( "id" ) DO UPDATE SET "name" = EXCLUDED."name", "security_number" = EXCLUDED."security_number"', + ); + } + + @override + Future update(Database db, List requests) async { + if (requests.isEmpty) return; + await db.query( + 'UPDATE "users"\n' + 'SET "name" = COALESCE(UPDATED."name"::text, "users"."name"), "security_number" = COALESCE(UPDATED."security_number"::text, "users"."security_number")\n' + 'FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.id)}, ${registry.encode(r.name)}, ${registry.encode(r.securityNumber)} )').join(', ')} )\n' + 'AS UPDATED("id", "name", "security_number")\n' + 'WHERE "users"."id" = UPDATED."id"', + ); + } + + @override + Future delete(Database db, List keys) async { + if (keys.isEmpty) return; + await db.query( + 'DELETE FROM "users"\n' + 'WHERE "users"."id" IN ( ${keys.map((k) => registry.encode(k)).join(',')} )', + ); + } +} + +abstract class CompanyRepository + implements + ModelRepository, + ModelRepositoryInsert, + ModelRepositoryUpdate, + ModelRepositoryDelete { + factory CompanyRepository._(Database db) = _CompanyRepository; + + Future queryDefaultView(String id); + Future> queryDefaultViews([QueryParams? params]); +} + +class _CompanyRepository extends BaseRepository + with + RepositoryInsertMixin, + RepositoryUpdateMixin, + RepositoryDeleteMixin + implements CompanyRepository { + _CompanyRepository(Database db) : super(db: db); + + @override + Future queryDefaultView(String id) { + return queryOne(id, DefaultCompanyViewQueryable()); + } + + @override + Future> queryDefaultViews([QueryParams? params]) { + return queryMany(DefaultCompanyViewQueryable(), params); + } + + @override + Future insert(Database db, List requests) async { + if (requests.isEmpty) return; + + await db.query( + 'INSERT INTO "companies" ( "id", "member_id" )\n' + 'VALUES ${requests.map((r) => '( ${registry.encode(r.id)}, ${registry.encode(r.memberId)} )').join(', ')}\n' + 'ON CONFLICT ( "id" ) DO UPDATE SET "member_id" = EXCLUDED."member_id"', + ); + } + + @override + Future update(Database db, List requests) async { + if (requests.isEmpty) return; + await db.query( + 'UPDATE "companies"\n' + 'SET "member_id" = COALESCE(UPDATED."member_id"::text, "companies"."member_id")\n' + 'FROM ( VALUES ${requests.map((r) => '( ${registry.encode(r.id)}, ${registry.encode(r.memberId)} )').join(', ')} )\n' + 'AS UPDATED("id", "member_id")\n' + 'WHERE "companies"."id" = UPDATED."id"', + ); + } + + @override + Future delete(Database db, List keys) async { + if (keys.isEmpty) return; + await db.query( + 'DELETE FROM "companies"\n' + 'WHERE "companies"."id" IN ( ${keys.map((k) => registry.encode(k)).join(',')} )', + ); + } +} + +@MappableClass() +class UserInsertRequest { + UserInsertRequest({required this.id, required this.name, required this.securityNumber}); + String id; + String name; + String securityNumber; +} + +class CompanyInsertRequest { + CompanyInsertRequest({required this.id, required this.memberId}); + String id; + String memberId; +} + +@MappableClass() +class UserUpdateRequest { + UserUpdateRequest({required this.id, this.name, this.securityNumber}); + String id; + String? name; + String? securityNumber; +} + +class CompanyUpdateRequest { + CompanyUpdateRequest({required this.id, this.memberId}); + String id; + String? memberId; +} + +class DefaultUserViewQueryable extends KeyedViewQueryable { + @override + String get keyName => 'id'; + + @override + String encodeKey(String key) => registry.encode(key); + + @override + String get tableName => 'default_users_view'; + + @override + String get tableAlias => 'users'; + + @override + DefaultUserView decode(TypedMap map) => DefaultUserView( + id: map.get('id', registry.decode), + name: map.get('name', registry.decode), + securityNumber: map.get('security_number', registry.decode)); +} + +@MappableClass() +class DefaultUserView { + DefaultUserView({required this.id, required this.name, required this.securityNumber}); + + final String id; + final String name; + final String securityNumber; +} + +class PublicUserViewQueryable extends KeyedViewQueryable { + @override + String get keyName => 'id'; + + @override + String encodeKey(String key) => registry.encode(key); + + @override + String get tableName => 'public_users_view'; + + @override + String get tableAlias => 'users'; + + @override + PublicUserView decode(TypedMap map) => + PublicUserView(id: map.get('id', registry.decode), name: map.get('name', registry.decode)); +} + +@MappableClass() +class PublicUserView { + PublicUserView({required this.id, required this.name}); + + final String id; + final String name; +} + +class DefaultCompanyViewQueryable extends KeyedViewQueryable { + @override + String get keyName => 'id'; + + @override + String encodeKey(String key) => registry.encode(key); + + @override + String get tableName => 'default_companies_view'; + + @override + String get tableAlias => 'companies'; + + @override + DefaultCompanyView decode(TypedMap map) => DefaultCompanyView( + id: map.get('id', registry.decode), member: map.get('member', PublicUserViewQueryable().decoder)); +} + +@MappableClass() +class DefaultCompanyView { + DefaultCompanyView({required this.id, required this.member}); + + final String id; + final PublicUserView member; +} diff --git a/test/packages/serialization/pubspec.yaml b/test/packages/serialization/pubspec.yaml new file mode 100644 index 0000000..7164d42 --- /dev/null +++ b/test/packages/serialization/pubspec.yaml @@ -0,0 +1,14 @@ +name: serialization_test + +environment: + sdk: '>=2.12.0 <3.0.0' + +dependencies: + stormberry: + path: ../../../ + dart_mappable: 1.2.0 + +dev_dependencies: + build_runner: ^2.1.7 + dart_mappable_builder: 1.2.0 + lints: ^1.0.1 \ No newline at end of file diff --git a/test/serialization_test.dart b/test/serialization_test.dart new file mode 100644 index 0000000..55662dd --- /dev/null +++ b/test/serialization_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + group('serialization', () { + test('generates outputs', () async { + var proc = await Process.start( + 'dart', + 'run build_runner build --delete-conflicting-outputs'.split(' '), + workingDirectory: 'test/packages/serialization', + ); + + expect(await proc.exitCode, equals(0)); + + var schema = File('test/packages/serialization/lib/models.schema.g.dart'); + var mapper = File('test/packages/serialization/lib/models.mapper.g.dart'); + + expect(schema.existsSync(), equals(true)); + expect(mapper.existsSync(), equals(true)); + }, timeout: Timeout(Duration(seconds: 60))); + + test('serialize models', () async { + + var proc = await Process.start( + 'dart', + 'run lib/main.dart'.split(' '), + workingDirectory: 'test/packages/serialization', + ); + + var output = await proc.stdout.map((e) => utf8.decode(e)).fold('', (s, e) => s + e); + + var lines = output.split('\n'); + + expect(lines, hasLength(5)); + + expect(lines[0], equals('{"id":"abc","name":"Tom","securityNumber":"12345"}')); + expect(lines[1], equals('DefaultUserView(id: abc, name: Alex, securityNumber: 12345)')); + expect(lines[2], equals('{"id":"01","member":{"id":"def","name":"Susan"}}')); + expect(lines[3], equals('{"id":"abc","name":null,"securityNumber":"007"}')); + expect(lines[4], equals('')); + + }, timeout: Timeout(Duration(seconds: 60))); + }); +}