2026年8月13日木曜日

Oracle Backend for FirebaseのRelational to collection mappingを使ってJSON Duality Viewをコレクションとして使用する

Oracle Backend for Firebase(Fusabase)の機能にRelational to collection mappingがあります。親子関係のある既存の表をRelational to collection mappingに登録することにより、Fusabase SDKより階層関係のあるコレクションおよびサブ・コレクションとして扱えるようになります。

Fusabase SDKに表をコレクションとして見せるために、JSON Duality Viewが作成されます。

本記事では、Oracle Backend for FirebaseのLiveLabs(Web/JavaScript、iOS/Swift、Android/Java)のデータの保存先としてリレーショナル表RECIPESとRATINGSを作成し、Relational to collection mappingに登録します。表RECIPESはコレクションrecipes、表RATINGSはサブ・コレクションratingsとしてアクセスできるようになりますが、JSON Duality Viewを介したコレクションと通常のコレクションを比較して、コーディング上で気を付ける点を確認します。

結果から先に挙げると、以下の4点について考慮が必要でした。
  1. リレーショナル表の列名は一般にスネークケース(単語を _ アンダースコアで連結)ですが、Relational to collection mappingは列名からJSONの属性名を決める際に、スネークケースの名前をそのまま使用します。JSONの属性名はキャメルケース(単語を区切らず2語目以降の先頭文字を大文字)にするのが一般的です。
  2. サブ・コレクションにドキュメントを保存する際に、親のコレクションのドキュメントも一緒に指定しますが、親となるドキュメントの主キーがサブ・コレクションに反映されません。サブ・コレクションのドキュメントに、明示的に親コレクションのドキュメントの主キー値を含める必要があります。
  3. JSON Duality Viewで日付データを扱う場合、Timestamp型を使用します。これはデータベースの列の型がDateやTimestampであるためです。JSONドキュメントに日付を含める場合はlongを使うこともありますが、JSON Duality Viewの場合、longの値はNUMBER型として保存します。
  4. 空文字列の扱いに注意が必要です。元表の列のデータ型がVARCHAR2の場合、Oracle Databaseでは空文字列をnullとして扱います。例えばJSONとして{ "ownerId": "" }を保存した場合、JSON Duality Viewから保存したデータを取り出すと{ "ownerId": null }が返されます。JSON列やJSONコレクション表に保存した場合は{ "ownerId": "" }が返されます。列のデータ型をCLOBにするとnullと空文字列を分けることができますが、空文字列としてEMPTY_CLOB()を与える必要があります。
LiveLabsの手順ではコレクションrecipesとratingsは、ドキュメントをコレクションに追加した時点で新規作成されます。これらのドキュメントを実表に保存するには、あらかじめデータの保存先となる表を作成しておく必要があります。

コレクションrecipesとサブ・コレクションratingsを作成した上で、LiveLabsのワークショップを進めます。


コレクションrecipesとサブ・コレクションratingsの作成



以下のDDLを実行し、レシピを保存する表RECIPESと評価を保存する表RATINGSを作成します。Oracle Backend for FirebaseのLiveLabsで扱うレシピや評価を保存できるように列を定義しています。
create table recipes (
    id              number generated by default on null as identity
                    constraint recipes_id_pk primary key,
    title           varchar2(80 char) not null,
    description     varchar2(800 char),
    category        varchar2(16 char) constraint recipes_category_ck
                    check (category in ('Breakfast','Lunch','Dinner','Dessert')),
    prep_time       number,
    -- ingradients is json array so the datatype is required to be json.
    ingredients     json,
    instructions    varchar2(4000 char),
    -- JavaScript lab does not use averageRating and ratingCount, iOS and Android do.
    average_rating  number,
    rating_count    number,
    photo_URL       varchar2(400 char),
    owner_id        varchar2(80 char),
    created_at      date default sysdate not null,
    created_by      varchar2(80 char)
);

create table ratings (
    id             number generated by default on null as identity
                   constraint ratings_id_pk primary key,
    recipe_id      number not null constraint ratings_recipe_id_fk
                   references recipes on delete cascade,
    author         varchar2(160 char) not null,
    rating         number not null constraint ratings_rating_ck
                   check (rating in (1,2,3,4,5)),
    -- JavaScript LiveLabs does not have created_at for ratings.
    created_at     date default sysdate not null,
    -- "comment" is the keyword of Oracle SQL.
    "COMMENT"      varchar2(80 char)
);

-- table index
create index ratings_i1 on ratings (recipe_id);
Oracle Backend for Firebaseを構成したスキーマに接続して実行します。

SQL> create table recipes (

  2      id              number generated by default on null as identity

  3                      constraint recipes_id_pk primary key,

  4      title           varchar2(80 char) not null,

  5      description     varchar2(800 char),

  6      category        varchar2(16 char) constraint recipes_category_ck

  7                      check (category in ('Breakfast','Lunch','Dinner','Dessert')),

  8      prep_time       number,

  9      -- ingradients is json array so the datatype is required to be json.

 10      ingredients     json,

 11      instructions    varchar2(4000 char),

 12      -- JavaScript lab does not use averageRating and ratingCount, iOS and Android do.

 13      average_rating  number,

 14      rating_count    number,

 15      photo_URL       varchar2(400 char),

 16      owner_id        varchar2(80 char),

 17      created_at      date default sysdate not null,

 18      created_by      varchar2(80 char)

 19* );


Table RECIPESは作成されました。


SQL> create table ratings (

  2      id             number generated by default on null as identity

  3                     constraint ratings_id_pk primary key,

  4      recipe_id      number not null constraint ratings_recipe_id_fk

  5                     references recipes on delete cascade,

  6      author         varchar2(160 char) not null,

  7      rating         number not null constraint ratings_rating_ck

  8                     check (rating in (1,2,3,4,5)),

  9      -- JavaScript LiveLabs does not have created_at for ratings.

 10      created_at     date default sysdate not null,

 11      -- "comment" is the keyword of Oracle SQL.

 12      "COMMENT"      varchar2(80 char)

 13* );


Table RATINGSは作成されました。


SQL> create index ratings_i1 on ratings (recipe_id);


Index RATINGS_I1は作成されました。


SQL> 


Oracle Backend for Firebaseのコンソールに接続し、DatabaseRelational to collection mappingを開きます。

Link existing tablesをクリックします。


Parent table namerecipesを入力し、サブ・コレクションのリンクをクリックします。Child table nameratingsを入力します。

データベースの表名に大文字小文字の区別はありませんが、ここで指定した表名がコレクションの名前になります。コレクション名は大文字と小文字が区別されます。LiveLabsで扱うコレクション名はrecipesおよびratingsなので、table nameは必ず小文字で指定します。

Saveをクリックするとメタデータが登録され、コレクションに対応したJSON Duality Viewが作成されます。


コレクションとしてrecipesとratingsが作成されます。


実際に作成されたJSON Duality Viewを確認します。SQLclなどで、プロジェクトのスキーマに接続します。

作成されたコレクションのメタデータを確認します。表BAAS_COLLECTION_METADATAを検索します。

select * from baas_collection_metadata;

列CONDITIONに、コレクションの実体となるJSON Duality ViewのDDLが記載されています。

SQL> select * from baas_collection_metadata;


TABLE_NAME      PATH                           PATH_HASH TABLE_TYPE    CREATED                        CONDITION                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       

_______________ __________________________ _____________ _____________ ______________________________ _______________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________ 

RECIPES$BAAS    /recipes                      3905671794 rel           26-08-12 02:45:26.450318000    create or replace json relational duality view "RECIPES$BAAS" as  select json {'title':"TITLE", 'description':"DESCRIPTION", 'category':"CATEGORY", 'prep_time':"PREP_TIME", 'ingredients':"INGREDIENTS", 'instructions':"INSTRUCTIONS", 'average_rating':"AVERAGE_RATING", 'rating_count':"RATING_COUNT", 'photourl':"PHOTOURL", 'owner_id':"OWNER_ID", 'created_at':"CREATED_AT", 'created_by':"CREATED_BY", 'OID' : SYS_MAKE_OID_FROM_PK("ID"),'_id':"ID",'parent_oid':'_docId'} from "RECIPES" with insert delete update    

RATINGS$BAAS    /recipes/_docId/ratings       1914104494 rel           26-08-12 02:45:26.452620000    create or replace json relational duality view "RATINGS$BAAS" as  select json {'recipe_id':"RECIPE_ID", 'author':"AUTHOR", 'rating':"RATING", 'created_at':"CREATED_AT", 'the_comment':"THE_COMMENT", 'OID' : SYS_MAKE_OID_FROM_PK("ID"),'_id':"ID",'parent_oid': GENERATED USING ( select '/' ||SYS_MAKE_OID_FROM_PK("RECIPES"."ID") from "RECIPES" where "RECIPES"."ID" = "RATINGS"."RECIPE_ID")} from "RATINGS" with insert delete update                                                                                    


SQL> 


スネークケースの列名がそのままJSONの属性名となっています。LiveLabsのコードを変更しないで済むように、LiveLabsで使用されている属性名でアクセスできるように、JSON Duality Viewを作り直します。

以下のDDLを実行します。
CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW "RECIPES$BAAS" AS
  SELECT JSON {
    'title'          : "TITLE",
    'description'    : "DESCRIPTION",
    'category'       : "CATEGORY",
    'prepTime'       : "PREP_TIME",
    'ingredients'    : "INGREDIENTS",
    'instructions'   : "INSTRUCTIONS",
    'photoURL'       : "PHOTO_URL",
    'ownerId'        : "OWNER_ID",
    'averageRating'  : "AVERAGE_RATING",
    'ratingCount'    : "RATING_COUNT",
    'createdAt'      : "CREATED_AT",
    'createdBy'      : "CREATED_BY",
    'OID'            : SYS_MAKE_OID_FROM_PK("ID"),
    '_id'            : "ID",
    'parent_oid'     : '_docId'
  }
  FROM "RECIPES"
  WITH INSERT DELETE UPDATE;

CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW "RATINGS$BAAS" AS
  SELECT JSON {
    'recipeId'   : "RECIPE_ID",
    'author'     : "AUTHOR",
    'rating'     : "RATING",
    'comment'    : "COMMENT",
    'createdAt'  : "CREATED_AT",
    'OID'        : SYS_MAKE_OID_FROM_PK("ID"),
    '_id'        : "ID",
    'parent_oid' : GENERATED USING (
      SELECT '/' || SYS_MAKE_OID_FROM_PK("RECIPES"."ID")
      FROM "RECIPES"
      WHERE "RECIPES"."ID" = "RATINGS"."RECIPE_ID"
    )
  }
  FROM "RATINGS"
  WITH INSERT DELETE UPDATE;

SQL> CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW "RECIPES$BAAS" AS

  2    SELECT JSON {

  3      'title'          : "TITLE",

  4      'description'    : "DESCRIPTION",

  5      'category'       : "CATEGORY",

  6      'prepTime'       : "PREP_TIME",

  7      'ingredients'    : "INGREDIENTS",

  8      'instructions'   : "INSTRUCTIONS",

  9      'photoURL'       : "PHOTO_URL",

 10      'ownerId'        : "OWNER_ID",

 11      'averageRating'  : "AVERAGE_RATING",

 12      'ratingCount'    : "RATING_COUNT",

 13      'createdAt'      : "CREATED_AT",

 14      'createdBy'      : "CREATED_BY",

 15      'OID'            : SYS_MAKE_OID_FROM_PK("ID"),

 16      '_id'            : "ID",

 17      'parent_oid'     : '_docId'

 18    }

 19    FROM "RECIPES"

 20*   WITH INSERT DELETE UPDATE;


View "RECIPES$BAAS"は作成されました。


SQL> CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW "RATINGS$BAAS" AS

  2    SELECT JSON {

  3      'recipeId'   : "RECIPE_ID",

  4      'author'     : "AUTHOR",

  5      'rating'     : "RATING",

  6      'comment'    : "COMMENT",

  7      'createdAt'  : "CREATED_AT",

  8      'OID'        : SYS_MAKE_OID_FROM_PK("ID"),

  9      '_id'        : "ID",

 10      'parent_oid' : GENERATED USING (

 11        SELECT '/' || SYS_MAKE_OID_FROM_PK("RECIPES"."ID")

 12        FROM "RECIPES"

 13        WHERE "RECIPES"."ID" = "RATINGS"."RECIPE_ID"

 14      )

 15    }

 16    FROM "RATINGS"

 17*   WITH INSERT DELETE UPDATE;


View "RATINGS$BAAS"は作成されました。


SQL> 


以上でJSON Duality Viewを使用するコレクションは準備できました。

今回の作業では参照しませんが、コレクションとサブ・コレクションの関係は表BAAS_RELATIONAL_METADATAに保存されています。

select * from baas_relational_metadata;

SQL> select * from baas_relational_metadata;


   ROOT PARENT     CHILD      PK        FK               

_______ __________ __________ _________ ________________ 

      0 recipes    ratings    ["ID"]    ["RECIPE_ID"]    

      1 recipes               ["ID"]                     

      0 ratings               ["ID"]                     


SQL> 


これから、プラットフォームごとのLiveLabsのワークショップの作業を始めます。

データの保存先をJSON Duality Viewに変更することにより、期待した動作にならないことがありました。その際には、LiveLabsのフォルダとSDKのフォルダをClaude Cowork(またはOpenAI ChatGPT Work)にアタッチして、解決策を探してもらいました。

対策のためのコードは、概ねAIに生成させています。


Web/JavaScript SDKでの対応



JavaScript SDKを使用するLiveLabsでは、Timestampや空文字列の対応がされています。表RATINGSへのRECIPE_IDの設定は対応が必要です。

Lab 5: Write Recipe Data, Task 3: Create a recipe and add a ratingの作業にて、ratingを追加しようとすると、以下のエラーが発生します。

ORA-20017: ORA-42692: Cannot insert into JSON Relational Duality View 'TESTUSER'.'RATINGS$BAAS': Error while inserting into table 'RATINGS'


評価(rating)は表RATINGSに保存されますが、その際に列RECIPE_IDの値が設定されていないために、NULL不可のエラーが発生しています。列RECIPE_IDに値が設定されていないと、レシピと評価の紐付けができません。

app.jsのratingを追加するボタンを押した時に実行される処理に、表RECIPESの親レコードのIDを表RATINGSのRECIPE_IDとして保存するコードを追加します。

app.jsの357行目近辺です。
  // ── Rating form ───────────────────────────────
  
  el.ratingForm.addEventListener("submit", (event) => {
    event.preventDefault();
    const fd = new FormData(el.ratingForm);
    runAction("Rating saved.", async () => {
      // ── Lab 5 TODO: Add ratings with the SDK ─────
      // Use doc() and addDoc(collection(recipeRef, "ratings"), ...) to save
      // a rating into the recipe's ratings subcollection.
  
      const recipeRef = doc(collection(db, "recipes"), state.activeRecipeId);
  
      // JSON Duality View Support:
      const recipe = state.recipes.find((r) => r.id === state.activeRecipeId);
      const recipePk = recipe._id;
    
      await addDoc(collection(recipeRef, "ratings"), {
        recipeId: recipePk, // JSON Duality View
        author: auth.currentUser.email,
        rating: Number(fd.get("rating") ?? "5"),
        comment: String(fd.get("comment") ?? "").trim()
      });

      el.ratingForm.reset(); 
      await refreshRecipes(state.activeRecipeId);
    }); 
  });
ratingを追加する対象のレシピの主キーの値をrecipePkに取り出して、サブ・コレクションratingsに追加するドキュメントにrecipeId: recipePkとして追加しています。


以上の対応でデータの保存先を表にした上で、Web/JavaScript SDKのLiveLabsを完走できました。


iOS/Swift SDKでの対応



iOS/Swift SDKを使用するLiveLabsでは、外部キーの設定、Timestamp、空文字列のすべての対応が必要です。さらにiOS SDKには利用できるTimestampクラスが含まれていないため、そのワークアラウンドも組み込む必要があります。

FusabaseのiOS SDKのFusabaseCoreに、パブリック・クラスとしてTimestampが含まれています。残念なことに、このTimestampクラスを使用してもJSON Duality Viewが日付として受け付ける文字列を生成できないようです。JavaScript SDKに含まれるTimestampクラスには、toTimestampString()、Android SDKに含まれるTimestampクラスにはgetString()といったタイムスタンプを文字列として取り出すメソッドが実装されていますが、iOSのTimestampには同等のメソッドが見当たりません。

仕方がないので、ワークアラウンドとしてDateと文字列を相互変換するクラスを、FusabaseTimestamp.swiftとしてAIに生成してもらいました。Modelsの下に作成します。



Models/Recipe.swiftではJSON Duality Viewの対応として、以下の4箇所(averageRatingの読み取り不具合についても修正しています - JSON Duality Viewとは無関係)を修正しています。

#1 54 - 56行目: ownerIdとして空文字列ではなくnullが返されるので、guardの条件から外しています。
#2 72 - 77行目: createdAtに時刻を表す文字列が返されるので、Stringのときの条件を追加しています。
85 - 88行目: JSON Duality Viewとは関係ありませんが、整数をDoubleとして取り出そうとするとキャストのエラーが発生し、0と見做されるので、一旦NSNumberで取り出してDoubleにしています。
#3 90 - 92行目: ownerIdがnuillのときに、空文字列を設定しています。
#4 104 - 106行目: createdAtに数値ではなく文字列を設定しています。

Lab 4: Read Recipe Data, Task 3: Seed demo data and verifyを動かすために、シード・データに含まれるcreatedAtの値を数値から文字列に置き換えます。

120行目
変更前: "createdAt": now - day,
変更後: "createdAt": FusabaseTimestamp.string(from: Date(timeIntervalSinceNow: -86400)),

141行目
変更前: "createdAt": now - (day / 2),
変更後: "createdAt": FusabaseTimestamp.string(from: Date(timeIntervalSinceNow: -43200)),

162行目
変更前: "createdAt": now - (day / 4),
変更後: "createdAt": FusabaseTimestamp.string(from: Date(timeIntervalSinceNow: -21600)),

183行目
変更前: "createdAt": now,
変更後: "createdAt": FusabaseTimestamp.string(from: Date()),

変数nowおよびdayは使わないので、コメントアウトします。

結果としてseedRecipesは以下になります。

Lab 4, Task 3の作業は上記の変更で完了します。

Lab 5: Write Recipe Data, Task 4: Create a recipe and add a ratingをを動かすためにコードを変更します。

RatingのモデルであるModels/Rating.swiftですが、createdAtの対応が抜けています。そのため5箇所に、createdAtを扱うコードを追加しています。

Services/RecipeService.swiftのcreateRecipeについては、createdAtに数値が渡されている部分を文字列に変更します。

258 - 260行目
変更前: "createdAt": Date().timeIntervalSince1970 * 1000.0,
変更後: "createdAt": FusabaseTimestamp.string(from: Date()),

結果としてcreateRecipeは以下になります。

addRecipeRatingについては、createdAtの値を数値から文字列に変更することに加えて、Web/JavaScript SDKのときと同様に、表RATINGSの列RECIPE_IDに、評価が紐づくレシピのIDを設定するコードを加える必要があります。

元のコードはratingDataを準備した後に親となるレシピのリファレンスrecipeRefを取り出しています。しかし、レシピのIDをratingDataに含める必要があるため、ratingDataより前にrecipeRefを取り出し、それからレシピの主キー値をrecipePkに取り出します。

取り出したrecipePkを、ratingDataにrecipeIdとして設定しています。

結果としてaddRecipeRatingは以下になります。

Lab 5, Task 4の作業は上記の変更で完了します。

以上の対応でデータの保存先を表にした上で、iOS/Swift SDKのLiveLabsを完走できました。


Android/Java SDKでの対応



Android/Java SDKを使用するLiveLabsでは、外部キーの設定、Timestampの対応が必要です。ただし、SDKがNPEを上げる不具合があり、ワークアラウンドの実装が必要です。

model/Recipe.javaに定義されているRecipeクラスですが、createdAtがlong型で定義されています。JSON Duality Viewで扱うには、これをcom.oracle.mobile.fusasbase.Timestampとして定義します。また、データベースから取り出したレシピのドキュメントからRecipeオブジェクトを生成する際に、RecipeRepository.java内ではQueryDocumentSnapshotクラスのtoObjectメソッドを呼び出していますが、このメソッドはドキュメントの属性値がnullのときにNPEが発生します。この不具合を回避するメソッドとしてfromMapを含めます。

これらの変更を加えたRecipe.javaは以下になります。

model/Rating.javaはcreatedAtを扱っていないため、fromMapメソッドだけを追加します。

モデルのクラスを変更した上で、Lab 4: Read Recipe Data, Task 6: Run and verifyを実行するための変更をRecipeRepository.javaに加えます。

最初にQueryDocumentSnapshotのtoObjectを呼び出している部分を、ワークアラウンドとして追加したfromMapメソッドの呼び出しに置き換えます。fromMapメソッド内でsetIdを呼び出しているため、後続のsetIdの呼び出しはコメントアウトしています。

103 - 110行目付近のコードを、以下のように書き換えます。
            for (QueryDocumentSnapshot doc : snapshot) {
                Recipe r = Recipe.fromMap(doc.getId(), doc.getData());
                // Recipe r = doc.toObject(Recipe.class);
                if (r != null) {
                    // r.setId(doc.getId());
                    mapped.add(r);
                }
            }
163 - 170行目付近のコードを、以下のように書き換えます。
            for (QueryDocumentSnapshot d : snapshot) {
                Rating r = Rating.fromMap(d.getId(), d.getData());
                // Rating r = d.toObject(Rating.class);
                if (r != null) {
                    // r.setId(d.getId());
                    mapped.add(r);
                }
            }
seedDemoRecipesメソッド内の、シード・データのcreatedAtの値がlongになっています。これをTimestampに置き換えます。

Timestampを扱うために、以下のクラスをインポート対象に追加します。
import java.util.Date;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import com.oracle.mobile.fusabase.Timestamp;
シード・データのcreatedAtに設定される値を、以下のように変更します。

変更前: now - day
変更後: new Timestamp(now.minus(1, ChronoUnit.DAYS))

変更前: now - (day / 2))
変更後: new Timestamp(now.minus(12, ChronoUnit.HOURS))

変更前: now - (day / 4))
変更後: new Timestamp(now.minus(6, ChronoUnit.HOURS))

変更前: now
変更後: Timestamp.now()

変更したseedDemoRecipesは以下になります。

seedDemoRecipes内で使用されているseedメソッドの引数createdAtの型がlongなので、Timestampに変更します。252行目付近です。

seedの引数のcreatedAtがlongで宣言されています。
    private static Map<String, Object> seed(String title, String description, String category,
                                                      long prepTime, String instructions,
                                                      List<String> ingredients, long createdAt)
Timestampに変更します。
    private static Map<String, Object> seed(String title, String description, String category,
                                                      long prepTime, String instructions,
                                                      List<String> ingredients, Timestamp createdAt) { // long to Timestamp
以上の変更により、Lab 4, Task 6は完了します。

Lab 5: Write Recipe Data, Taask 3: Run and verifyを実行するための変更をRecipeRepository.javaに加えます。

createRecipeメソッド内でcreatedAtにlongの値が設定されている部分を、Timestampに変更します。

308行目近辺です。
        data.put("createdAt", Timestamp.now()); // long to Timestamp
        // data.put("createdAt", System.currentTimeMillis());

addRatingについては、Ratingのドキュメントに評価対象のレシピのIDを含める必要があるため、コードを全面的に変更します。

以上の変更により、Lab 5, Task 3は完了します。

以上の対応でデータの保存先を表にした上で、Android/Java SDKのLiveLabsを完走できました。

今回の記事は以上になります。