schultek / stormberry

Access your postgres database effortlessly from dart code.
https://pub.dev/packages/stormberry
MIT License
66 stars 16 forks source link

ConverterException: Parameter likes is not a List #50

Closed mhmzdev closed 1 year ago

mhmzdev commented 1 year ago

I've added just a List<int> likes into the Post model. Then added some dummy data in postgres SQL. But it seems that stormberry isn't parsing the list correctly.

The rest of the models are working fine, only with List in them are throwing this exception. I'm using 0.11.0 latest version

ConverterException: Parameter likes is not a List
package:stormberry/src/internals/text_encoder.dart 137:7   TypedMap.getList
package:stormberry/src/internals/text_encoder.dart 147:12  TypedMap.getListOpt
package:db/src/models/post/post.schema.dart 143:18         PostQueryable.decode
package:stormberry/src/internals/view_query.dart 48:46     ViewQuery.apply.<fn>
dart:_internal                                             ListIterable.toList
package:stormberry/src/internals/view_query.dart 48:83     ViewQuery.apply

Model class and its schema file code is below:

post.dart

@Model()
abstract class Post {
  @PrimaryKey()
  @AutoIncrement()
  int get id;

  int get uid;
  String get caption;
  bool? get hasImage;
  String? get imageUrl;
  bool? get hasVideo;
  String? get videoUrl;
  List<int> get likes;
  List<Comment> get comments;

  DateTime get createdAt;
}

post.schema.dart

part of 'post.dart';

extension PostRepositories on Database {
  PostRepository get posts => PostRepository._(this);
}

abstract class PostRepository
    implements
        ModelRepository,
        KeyedModelRepositoryInsert<PostInsertRequest>,
        ModelRepositoryUpdate<PostUpdateRequest>,
        ModelRepositoryDelete<int> {
  factory PostRepository._(Database db) = _PostRepository;

  Future<Post?> queryPost(int id);
  Future<List<Post>> queryPosts([QueryParams? params]);
}

class _PostRepository extends BaseRepository
    with
        KeyedRepositoryInsertMixin<PostInsertRequest>,
        RepositoryUpdateMixin<PostUpdateRequest>,
        RepositoryDeleteMixin<int>
    implements PostRepository {
  _PostRepository(super.db) : super(tableName: 'posts', keyName: 'id');

  @override
  Future<Post?> queryPost(int id) {
    return queryOne(id, PostQueryable());
  }

  @override
  Future<List<Post>> queryPosts([QueryParams? params]) {
    return queryMany(PostQueryable(), params);
  }

  @override
  Future<List<int>> insert(List<PostInsertRequest> requests) async {
    if (requests.isEmpty) return [];
    var values = QueryValues();
    var rows = await db.query(
      'INSERT INTO "posts" ( "uid", "caption", "has_image", "image_url", "has_video", "video_url", "likes", "created_at" )\n'
      'VALUES ${requests.map((r) => '( ${values.add(r.uid)}:int8, ${values.add(r.caption)}:text, ${values.add(r.hasImage)}:bool, ${values.add(r.imageUrl)}:text, ${values.add(r.hasVideo)}:bool, ${values.add(r.videoUrl)}:text, ${values.add(r.likes)}:_int8, ${values.add(r.createdAt)}:timestamp )').join(', ')}\n'
      'RETURNING "id"',
      values.values,
    );
    var result = rows.map<int>((r) => TextEncoder.i.decode(r.toColumnMap()['id'])).toList();

    return result;
  }

  @override
  Future<void> update(List<PostUpdateRequest> requests) async {
    if (requests.isEmpty) return;
    var values = QueryValues();
    await db.query(
      'UPDATE "posts"\n'
      'SET "uid" = COALESCE(UPDATED."uid", "posts"."uid"), "caption" = COALESCE(UPDATED."caption", "posts"."caption"), "has_image" = COALESCE(UPDATED."has_image", "posts"."has_image"), "image_url" = COALESCE(UPDATED."image_url", "posts"."image_url"), "has_video" = COALESCE(UPDATED."has_video", "posts"."has_video"), "video_url" = COALESCE(UPDATED."video_url", "posts"."video_url"), "likes" = COALESCE(UPDATED."likes", "posts"."likes"), "created_at" = COALESCE(UPDATED."created_at", "posts"."created_at")\n'
      'FROM ( VALUES ${requests.map((r) => '( ${values.add(r.id)}:int8, ${values.add(r.uid)}:int8, ${values.add(r.caption)}:text, ${values.add(r.hasImage)}:bool, ${values.add(r.imageUrl)}:text, ${values.add(r.hasVideo)}:bool, ${values.add(r.videoUrl)}:text, ${values.add(r.likes)}:_int8, ${values.add(r.createdAt)}:timestamp )').join(', ')} )\n'
      'AS UPDATED("id", "uid", "caption", "has_image", "image_url", "has_video", "video_url", "likes", "created_at")\n'
      'WHERE "posts"."id" = UPDATED."id"',
      values.values,
    );
  }
}

class PostInsertRequest {
  PostInsertRequest({
    required this.uid,
    required this.caption,
    this.hasImage,
    this.imageUrl,
    this.hasVideo,
    this.videoUrl,
    required this.likes,
    required this.createdAt,
  });

  int uid;
  String caption;
  bool? hasImage;
  String? imageUrl;
  bool? hasVideo;
  String? videoUrl;
  List<int> likes;
  DateTime createdAt;
}

class PostUpdateRequest {
  PostUpdateRequest({
    required this.id,
    this.uid,
    this.caption,
    this.hasImage,
    this.imageUrl,
    this.hasVideo,
    this.videoUrl,
    this.likes,
    this.createdAt,
  });

  int id;
  int? uid;
  String? caption;
  bool? hasImage;
  String? imageUrl;
  bool? hasVideo;
  String? videoUrl;
  List<int>? likes;
  DateTime? createdAt;
}

class PostQueryable extends KeyedViewQueryable<Post, int> {
  @override
  String get keyName => 'id';

  @override
  String encodeKey(int key) => TextEncoder.i.encode(key);

  @override
  String get query => 'SELECT "posts".*, "comments"."data" as "comments"'
      'FROM "posts"'
      'LEFT JOIN ('
      '  SELECT "comments"."post_id",'
      '    to_jsonb(array_agg("comments".*)) as data'
      '  FROM (${CommentQueryable().query}) "comments"'
      '  GROUP BY "comments"."post_id"'
      ') "comments"'
      'ON "posts"."id" = "comments"."post_id"';

  @override
  String get tableAlias => 'posts';

  @override
  Post decode(TypedMap map) => PostView(
      id: map.get('id'),
      uid: map.get('uid'),
      caption: map.get('caption'),
      hasImage: map.getOpt('has_image'),
      imageUrl: map.getOpt('image_url'),
      hasVideo: map.getOpt('has_video'),
      videoUrl: map.getOpt('video_url'),
      likes: map.getListOpt('likes') ?? const [],
      comments: map.getListOpt('comments', CommentQueryable().decoder) ?? const [],
      createdAt: map.get('created_at'));
}

class PostView with Post {
  PostView({
    required this.id,
    required this.uid,
    required this.caption,
    this.hasImage,
    this.imageUrl,
    this.hasVideo,
    this.videoUrl,
    required this.likes,
    required this.comments,
    required this.createdAt,
  });

  @override
  final int id;
  @override
  final int uid;
  @override
  final String caption;
  @override
  final bool? hasImage;
  @override
  final String? imageUrl;
  @override
  final bool? hasVideo;
  @override
  final String? videoUrl;
  @override
  final List<int> likes;
  @override
  final List<Comment> comments;
  @override
  final DateTime createdAt;
}
schultek commented 1 year ago

I found the issue. Its with int4 vs int8. Stormberry uses int8 by default for all integer types, including lists of ints -> _int8. However the postgres package currently only supports _int4 (list of int4) and therefore has a type mismatch both when inserting and querying data.

For querying you can work around this by changing the likes database column manually to _int4. For inserting/updating you would have to modify the post.schema.dart file and replace all :_int8 with :_int4.

I will try to open a pr on the postgres package to add support, but that might take some time until released

schultek commented 1 year ago

@mhmzdev This is already merged. When updating postgres it should work without any changes to stormberry.

mhmzdev commented 1 year ago

Awesome!! @schultek Thank you so much for figuring this out.

Lemme, try updating postgres and see if it works

mhmzdev commented 1 year ago

Worked like a charm! ✅ Closing the issue.