flutter drift 패키지는 앱에서 데이터베이스를 사용할 수 있도록 도와주는 강력한 기능을 가진 패키지입니다. 이번 글에서는 데이터베이스 관리의 심화과정으로 drift 다대다(many-many) 모델 구현방법에 대해서 알아보도록 하겠습니다.
Flutter drift 다대다(many-many) 모델 구현하기
이 글은 flutter 공식 홈페이지의 내용을 기반으로 작성하였습니다.
모델 정의
먼저 모델을 정의해 주도록 하겠습니다. 구현할 예제는 3가지 테이블이 정의됩니다. 첫번째는 BuyableTimes라는 테이블로써 id, description, price의 구매가능물품의 정보를 담고 있는 테이블입니다.
class BuyableItems extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get description => text()(); IntColumn get price => integer()(); // we could add more columns as we wish. }
쇼핑카트와 관련된 테이블은 두 개가 있는데 하나는 쇼핑카트 자체를 정의한 테이블이고 다른 하나는 외래키(foreign key)를 이용해서 쇼핑카트와 구매가능물품을 함께 정의한 테이블입니다.
class ShoppingCarts extends Table { IntColumn get id => integer().autoIncrement()(); } // shoppingCartEntries 테이블 내 데이터를 ShoppingCartEntry로 정의합니다. @DataClassName('ShoppingCartEntry') class ShoppingCartEntries extends Table { // id of the cart that should contain this item. IntColumn get shoppingCart => integer().references(ShoppingCarts, #id)(); // id of the item in this cart IntColumn get item => integer().references(BuyableItems, #id)(); // again, we could store additional information like when the item was // added, an amount, etc. }
세 개로 별도로 구분된 테이블을 각각 불러다 쓰는데 불편함이 있기 때문에 새로운 클래스를 정의합니다. ShoppingCartEntries 테이블에는 ShoppingCart에 담겨있는 BuyableItem이 서로 연결되어 정의되어 있습니다. 예를들면 1번 ShoppingCart에 2번과 34번 BuyableItem이 담겨있으면 테이블 데이터가 다음과 같이 저장될 것입니다.
shopping_cart | item |
1 | 2 |
1 | 34 |
아이템에 대한 설명이나 가격정보를 알고 싶으면 item 값과 BuyableItem 테이블을 연계하여 정보를 불러와야 합니다. 즉 join기능을 사용해야 합니다. 일단 join을 수행했을 때 최종적으로 보여지게 될 구조를 정의한 테이블을 새롭게 만들도록 하겠습니다.
/// Represents a full shopping cart with all its items. class CartWithItems { final ShoppingCart cart; final List<BuyableItem> items; CartWithItems(this.cart, this.items); }
정확히 이야기하면 이 클래스는 흔히 쿼리의 결과로써 보여지는 테이블구조는 아닙니다. 안에 정의되어 있는 컨스트럭터에서도 볼 수 있듯이 하나의 ShoppingCart에 대해서 List로써의 BuyableItem이 정이되어 있습니다. 결과는 SQL을 이용하여 join을 하였을때와 동일합니다. 다만 SQL의 테이블은 모든 아이템에 대해서 ShoppingCart정보가 중복되어 기록이 되는 것이고 위에 정의된 클래스에서는 중복없이 하나로만 표현을 한다의 차이가 있는 것이죠.
입력하기
사용자의 쇼핑정보를 입력하는 방법에 대해서 먼저 확인해 보도록 하겠습니다. 데이터베이스 관리의 편의성을 위해서 쇼핑카트정보, 아이템정보, 쇼핑카트 별 아이템 매칭정보를 별도의 테이블로 관리하였습니다. 이제 사용자가 물품을 구매한 뒤 자신의 쇼핑카트에 담을 때 앞서 정의한 CartWithItems 인스턴스를 데이터베이스에 저장하는 방법에 대해서 설명하도록 하겠습니다.
Future<void> writeShoppingCart(CartWithItems entry) { return transaction(() async { final cart = entry.cart; // first, we write the shopping cart await into(shoppingCarts).insert(cart, mode: InsertMode.replace); // we replace the entries of the cart, so first delete the old ones await (delete(shoppingCartEntries) ..where((entry) => entry.shoppingCart.equals(cart.id))) .go(); // And write the new ones for (final item in entry.items) { await into(shoppingCartEntries) .insert(ShoppingCartEntry(shoppingCart: cart.id, item: item.id)); } }); }
먼저 writeShoppingCart 클래스는 CartWithItems 타입의 entry라는 인자를 전달받습니다. entry에는 ShoppingCart정보와 카트에 저장된 List<BuyableItems> 정보가 저장되어 있습니다.
- transaction은 내부에 작업이 모두 정상적으로 수행될 때 나중에 한꺼번에 데이터베이스에 업데이트하기 위한 함수입니다.
- 먼저 entry의 ShoppingCart 정보를 cart 변수에 저장합니다. CartWithItems 정보를 이용해서 ShoppingCarts 테이블과 ShoppingCartEntries 테이블을 각각 업데이트합니다. ShoppingCarts에 카트 정보를 입력할 때에는 기존에 존재하는 카트일 수 있기 때문에 insertMode를 replace로 설정하여 중복입력 시 에러가 발생하지 않도록 합니다.
- 다음으로 ShoppingCartEntries 테이블의 경우 기존의 cart의 id와 같은 정보를 찾아서 지운다음에 새롭게 입력하는 방법으로 진행합니다. 따라서 writeShoppingCart를 수행하게되면 해당 쇼핑카트의 기존 아이템목록은 모두 지워지고 새롭게 추가한 목록만 남게 됩니다.
카트정보 불러오기 – single cart
현재 CartWithItems 클래스는 복수의 테이블로부터 정보를 종합해서 저장하고 있습니다. 그래서 이번 장에서는 rxdart를 이용해서 두개 이상의 스트림을 관리하는 방법에 대해서 알아보고자 합니다. 먼저 코드는 다음과 같습니다.
Stream<CartWithItems> watchCart(int id) { // load information about the cart final cartQuery = select(shoppingCarts) ..where((cart) => cart.id.equals(id)); // and also load information about the entries in this cart final contentQuery = select(shoppingCartEntries).join( [ innerJoin( buyableItems, buyableItems.id.equalsExp(shoppingCartEntries.item), ), ], )..where(shoppingCartEntries.shoppingCart.equals(id)); final cartStream = cartQuery.watchSingle(); final contentStream = contentQuery.watch().map((rows) { // we join the shoppingCartEntries with the buyableItems, but we // only care about the item here. return rows.map((row) => row.readTable(buyableItems)).toList(); }); // now, we can merge the two queries together in one stream return Rx.combineLatest2(cartStream, contentStream, (ShoppingCart cart, List<BuyableItem> items) { return CartWithItems(cart, items); }); }
코드를 보면 cartQuery와 contentQuery, 그리고 cartStream과 contentStream 이 각각 정의되어 있고 마지막으로 return 구문을 확인하실 수 있습니다. watchCart는 CartWithItems를 지속적으로 모니터링할 수 있도록 Stream 형식으로 정의되어 있습니다. 그리고 특정 카트의 정보를 얻기 위해서 id값을 인자로 전달받습니다.
- cartQuery는 shoppingCarts 테이블에서 id에 해당하는 shoppingCart정보를 검색하기 위한 퀴리입니다. select, where함수를 이용해서 검색합니다.
- contentQuery의 경우 shoppingCartEntries 테이블로부터 검색을 하는 쿼리입니다. 기억을 거슬러 올라가보면 shoppingCartEntries는 shoppingCart의 id정보와 BuyableItem의 id정보를 가지고 있습니다. 인자로 전달받은 id정보는 shoppingCart의 id 정보이기 때문에 쿼리의 가장 마지막에 where절을 이용해서 shoppingCartEntries 테이블의 shoppingCart 컬럼에서 id가 같은 데이터만을 검색하도록 하고 있습니다.
- contentQuery는 shoppingCartEntries 테이블에 select 함수를 이용했는데 앞서 cartQuery와의 차이점은 join 함수를 추가로 사용했다는 것입니다. innerJoin에는 두 개의 인자가 전달되는데 첫번째는 join으로 추가할 테이블 혹은 컬럼을 읨하고 다음으로는 inner join 조건을 의미합니다. 해석하자면 shoppingCartEntries 테이블의 items 컬럼의 id값과 id값이 일치하는 buyableItems의 테이블을 모두 추가한다는 의미입니다.
- 이로써 cartQuery를 통해서 카트 정보를, contentQuery를 통해서 해당 카트에 연결된 아이템 정보를 모두 가져올 수 있게 됩니다. 이후 정의된 cartStream과 contentStream은 이후 변동사항에 대해서 Stream으로 관리하겠다는 의미입니다. cartQuery의 경우 유일한 데이터를 리턴받기 때문에 watchSingle 함수를 이용하였습니다.
- contentQuery의 경우 일대다(1 to many) 대응이 발생합니다. 따라서 watchSingle이 아닌 watch를 이용합니다. map함수를 이용하여 결과값인 rows가 인자로 전달받게 되는데 각각의 row에 대하여 readTable을 이용하여 buyableItem 타입의 리스트 형태로 리턴합니다.
- 마지막으로 서로 각기 구현한 두 개의 스트림은 rxdart의 combineLatest2 함수를 이용하여 하나의 스트림으로 관리할 수 있도록 만들어줍니다.
카드정보 불러오기 – all carts
앞선 예제는 한 개의 id에 해당하는 카트정보를 출력하는 상황에 대해 설명하였습니다. 이번에 설명할 내용은 모든 카트 안에 있는 정보를 출력하는 방법에 대해서 알아보도록 하겠습니다. 먼저 코드를 보면 다음과 같습니다.
Stream<List<CartWithItems>> watchAllCarts() { // start by watching all carts final cartStream = select(shoppingCarts).watch(); return cartStream.switchMap((carts) { // this method is called whenever the list of carts changes. For each // cart, now we want to load all the items in it. // (we create a map from id to cart here just for performance reasons) final idToCart = {for (var cart in carts) cart.id: cart}; final ids = idToCart.keys; // select all entries that are included in any cart that we found final entryQuery = select(shoppingCartEntries).join( [ innerJoin( buyableItems, buyableItems.id.equalsExp(shoppingCartEntries.item), ) ], )..where(shoppingCartEntries.shoppingCart.isIn(ids)); return entryQuery.watch().map((rows) { // Store the list of entries for each cart, again using maps for faster // lookups. final idToItems = <int, List<BuyableItem>>{}; // for each entry (row) that is included in a cart, put it in the map // of items. for (final row in rows) { final item = row.readTable(buyableItems); final id = row.readTable(shoppingCartEntries).shoppingCart; idToItems.putIfAbsent(id, () => []).add(item); } // finally, all that's left is to merge the map of carts with the map of // entries return [ for (var id in ids) CartWithItems(idToCart[id]!, idToItems[id] ?? []), ]; }); }); }
- 해당 위젯의 리턴타입은 CartWithItems 타입의 원소를 갖는 리스트 형식입니다.
- 첫번째로 정의하는 cartStream은 한개의 cart가 아닌 전체 cart를 조회하기 때문에 watch() 함수를 이용해서 stream을 정의합니다. 이렇게 정의한 cartStream에 switchMap 함수를 적용합니다.
- cartStream으로부터 shoppingCart의 목록을 carts로 전달받습니다. for문을 통해서 각각의 cart정보에 접근하여 cart의 id값을 idToCart에 저장합니다. idToCart의 key값을 ids 정보로 저장합니다.
- entryQuery는 shoppingCartEntries의 item의 id값과 같은 buyableItem을 연결하는 inner join 쿼리를 구현합니다. 이 때 하나의 id값이 아닌 shoppingCart의 전체 id값을 ids라는 이름으로 받기 때문에 where구문에서 inIn(ids)라는 조건을 부여하게 됩니다.
- 이렇게 검색한 stream정보를 다시 map함수를 이용해서 매핑을 하게 되는데 이 때 idToItems를 정의합니다. idToItems는 int와 List<BuyableItem>을 동시에 입력받는 형태로 정의되어 있습니다.
- entryQuery로부터 전달받은 rows에 대해서 각 row의 buyableItems 정보는 item으로, shoppingCartEntries의 shoppngCart의 id정보는 id로 저장한 뒤 이를 idToItems에 추가합니다.
- 마지막으로 ids의 각각의 id별로 for loop을 돌면서 idToCart에서 카트의 id정보를, idToItems에서 id에 해당하는 buyableItems값을 각각 조회ㅇ하여 CartWithItems에 추가한 뒤 리스트를 리턴합니다.