2026年8月14日金曜日

Oracle Backend for FirebaseのStorageの構成をDBFSからOCI Object Storageに切り替える

Oracle Backend for Firebase(Fusabase)ではStorageに保存するファイルの実際の保存先として、DBFS(Database File System - つまりOracle Database)またはOCI Object Storageを選択できます。デフォルトではDBFSが構成されます。

一般にファイルとして保存するデータは、PowerPointなどのファイルや画像、音声などサイズが大きいデータです。Oracle Backend for FirebaseのStorageの保存先がDBFSで構成されていると、これらのサイズの大きいデータがデータベースに保存されます。

Fusabase SDKによるStorageへのデータのアップロードでは、クライアント(Web、iOSアプリ、Androidアプリ)からORDSへデータが送信され、次にORDSからデータベースにデータが送信されます。ダウンロードの場合は、データベースからORDSにデータが送信され、次にORDSからクライアントにデータが送信されます。通常、クライアントとORDSの間のネットワークの速度はそれほど速くないため、そこで律速されます。

問題は、ダウンロードが完了するまではORDSとデータベースの間の接続が占有されることです。処理時間はクライアントとORDS間のネットワーク速度に依存します。データベースに負荷はほとんどかかりませんが、ファイルのアップロードまたはダウンロードが完了するまではORDSとデータベース間のセッションが使用中となるため、利用できる接続を確保するにはORDSのコネクション・プールが保持する接続の上限を増やす必要があります。また、データベースからデータを取り出し、クライアントに送信する(またはその逆)間に、転送するデータをORDSが保持するため、ORDSが扱えるメモリの上限も上げる必要があります。Oracle Backend for FirebaseのStorageをDBFSで構成するのはお手軽ですがスケールしないため、利用者が少ないなど負荷がかからない状況でなければ実運用は難しいのではないかと思います。

Oracle Backend for FirebaseではStorageの保存先としてOCI Object Storageを構成することができます。この場合、クライアントからのファイルのアップロードは、クライアント、ORDS、データベース、Object Storageという経路で、最終的にObject Storageにファイルが配置されます。ダウンロードについては、Object Storageから直接クライアントにダウンロードされ、ORDSおよびデータベースは経由しません。ファイルのアップロードよりもダウンロードの方が頻度は高く、その経路からORDSやデータベースが外れるため、ORDSが必要とするリソースも大幅に少なくなります。

本記事ではOracle Backend for FirebaseのStorageの構成を、OCI Object Storageに切り替える手順を紹介します。

検証した範囲では、StorageとしてOCI Object Storageを構成すると、Relational to collection mapping(実体はJSON Duality View)によって作成したコレクションにStorageを紐づけられませんでした。Storageを紐づけるコレクションは、通常のコレクションに限定されます。

構成方法は、以下の公式ドキュメントに記載されています。

Oracle Backend for Firebase 26.1
Developer's Guide
Part IV Storage
25 Storage Setup and Configuration

動作確認に使用するアプリケーションとして、以下のLiveLabsで作成したWebアプリケーションを使用します。

Build a Recipe Web App with Oracle Backend for Firebase
https://livelabs.oracle.com/ords/r/dbpm/livelabs/view-workshop?wid=4404

OCI Object Storageの構成を行う前に、デフォルトで構成されるDBFSについて確認します。


DBFSの構成



Oracle Backend for Firebaseのコンソールに接続し、StorageFile systemを開きます。


Storageの保存先(File system)のTypeDBFSRegiondbfsBucket namedbfs_<任意の文字列>として構成されています。

SQLclでプロジェクトのスキーマに接続し、以下のSELECT文を実行しDBFSに保存されているファイルを確認します。

select store, pathname from dbfs_content;

SQL> select store, pathname from dbfs_content;


STORE                   PATHNAME                                 

_______________________ ________________________________________ 

dbfs_FDNSTBCAVUSCPHZ    /dbfs_FDNSTBCAVUSCPHZ                    

dbfs_FDNSTBCAVUSCPHZ    /dbfs_FDNSTBCAVUSCPHZ/.sfs/tools         

dbfs_FDNSTBCAVUSCPHZ    /dbfs_FDNSTBCAVUSCPHZ/.sfs/snapshots     

dbfs_FDNSTBCAVUSCPHZ    /dbfs_FDNSTBCAVUSCPHZ/.sfs/content       

dbfs_FDNSTBCAVUSCPHZ    /dbfs_FDNSTBCAVUSCPHZ/.sfs/attributes    

dbfs_FDNSTBCAVUSCPHZ    /dbfs_FDNSTBCAVUSCPHZ/.sfs/RECYCLE       

dbfs_FDNSTBCAVUSCPHZ    /dbfs_FDNSTBCAVUSCPHZ/.sfs               


7行が選択されました。 


SQL> 


ビューDBFS_CONETNTのSTORE列の値が、Bucket nameとなっています。Storageにアップロードされたファイルは、このStoreに保存されます。

ファイルが参照される場合は、このStoreからファイルが取り出されます。

すでに作成済みのDBFSのFile systemも、ビューDBFS_CONTENTのSTORE列から確認できます。


OCI Object Storageの準備



OCIのオブジェクト・ストレージにバケットを作成します。Oracle Backend for Firebase向けの特別なバケットというものはありませんが、ポリシーとしてバケットへのファイルの読み書きと事前承認済リクエストの作成を許可する必要があります。

バケットの準備として、以下の作業を行います。
  1. コンパートメントFusabaseの作成
  2. バケットfusabase-storageの作成
  3. APIユーザーfusabase-storage-managerの作成
  4. グループFusabaseStorageManagersの作成
  5. ポリシーFusabaseStoragePolicyの作成
  6. APIユーザーfusabase-storage-userのAPIキーの作成

コンパートメントFusabaseの作成



Oracle Cloudのコンソールに接続し、アイデンティティとセキュリティコンパートメントを開きます。

コンパートメントの作成を実行します。


作成するコンパートメントの名前Fusabaseとします。親コンパートメントルートを選択し、コンパートメントを作成します。


コンパートメントFusabaseが作成されました。



バケットfusabase-storageの作成



作成したコンパートメントFusabaseに、オブジェクト・ストレージのバケットとしてfusabase-storageを作成します。

ストレージバケットを開き、コンパートメントFusabaseを選択します。

バケットの作成を実行します。


作成するバケット名fusabase-storageとします。それ以外はデフォルトのまま、バケットの作成を実行します。


バケットfusabase-storageが作成されました。



APIユーザーfusabase-storage-managerの作成



Object Storageの操作に使用するユーザーとしてfusabase-storage-managerを作成します。ユーザーはルート・コンパートメントに作成済みのDefaultドメインに作成します。

アイデンティティとセキュリティドメインを開き、ルート・コンパートメントを選択します。

作成済みのドメインDefaultを開きます。


ユーザー管理タブを開き、ユーザーの作成を実行します。


ユーザー名として電子メール・アドレスを使用チェックを外します。

Fusabaseユーザー名fusabase-storage-managerとします。電子メールを設定し、ユーザーを作成します。


ユーザーfusabase-storage-managerが作成されました。APIキーの生成は最後に実施します。



グループFusabaseStorageManagersの作成




ポリシーのサブジェクトとして使うグループとしてFusabaseStorageManagersを作成します。

Defaultドメインのユーザー管理より、グループの作成を実行します。


作成するグループの名前FusabaseStorageManagersとします。ユーザーとして先ほど作成したfusabase-storage-managerチェックし、グループに割り当てます。

以上で作成します。


グループFusabaseStorageManagersが作成されました。



ポリシーFusabaseStoragePolicyの作成




グループFusabaseStorageManagersに所属しているユーザーによる、コンパートメントFusabaseにあるバケットfusabase-storageへのオブジェクトの読み書き、および事前認証済リクエストの生成を許可するポリシーを設定します。

アイデンティティとセキュリティポリシーを開き、ルート・コンパートメントを選択します。

ポリシーの作成を実行します。


作成するポリシーの名前FusabaseStoragePolicyとします。コンパートメントルートを選択し、手動エディタに切り替えます。

ポリシーとして以下の3行を記述します。
Allow group FusabaseStorageManagers to read buckets in compartment Fusabase
Allow group FusabaseStorageManagers to manage objects in compartment Fusabase where target.bucket.name = 'fusabase-storage'
Allow group FusabaseStorageManagers to manage buckets in compartment Fusabase where all { request.permission = 'PAR_MANAGE', target.bucket.name = 'fusabase-storage' }
以上で作成します。


ポリシーFusabaseStoragePolicyが作成されました。



APIユーザーfusabase-storage-userのAPIキーの作成




作成済みのユーザーfusabase-storage-managerAPIキーを開きます。

APIキーの作成を実行します。


APIキー・ペアの生成を選択し、秘密キーのダウンロードを実行します。


秘密キーがファイルとしてダウンロードされると、APIキーの追加ができるようになります。

秘密キーのダウンロードだけではAPIキーとして登録されないため、必ず追加をクリックします。


構成ファイルとして、Oracle Backend for FirebaseのStorageをOCI Object Storageとして構成するために必要なパラメータの値が表示されます。

構成ファイルの内容をコピーし保管しておきます。

保管したのち、ドロワーを閉じます


以上でユーザーfusabase-storage-managerAPIキーが作成されました。


最後にプロファイルからテナンシ詳細を開き、オブジェクト・ストレージ・ネームスペースを確認します。


以上でOCI Object Storageへの切り替えに必要な情報がすべて揃いました。


OCI Object Storageへの切り替え



Oracle Backend for Firebaseのコンソールに接続し、StorageFile systemを開きます。

Manage configurationをクリックします。


Link to OCI Object Storageをクリックすると、切り替えに必要な設定値の入力フォームが表示されます。

OCI Object Storageの準備作業で集めた以下の情報を設定します。

Namespace、User OCID、Tenancy OCID、Fingerprint、Private key(ダウンロードしたファイルに記載)、Bucket name(これはfusabase-storage)、Region


Bucket nameとRegionの入力するために、フォームを下にスクロールさせます。

Private keyの入力フィールドに、Must start with "-----BEGIN PRIVATE KEY-----" and end with "-----END PRIVATE KEY-----"と表示されています。

秘密キーの入力は-----BEGIN PRIVATE KEY-----から始まり、-----END PRIVATE KEY-----で終わるように入力しますが、形式の認識に問題があり警告が表示されます。


最初の行に続く行を、以下のように連結します。
-----BEGIN PRIVATE KEY-----MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDnxhnPjsuFbOYU
また、-----END PRIVATE KEY-----の後に空行がないことも確認します。

Private keyの入力フィールドからフォーカスが移動すると、入力した秘密キーの検証が行われます。上記の対応をすると、警告が消えます。

以上の入力を行いSaveを実行します。


StorageのFile systemがOCI Object Storageに切り替わると、TypeOCIBucket namefusabase-storageRegionOCIのリージョンNamespaceにテナントのオブジェクト・ストレージ・ネームスペースが表示されます。




動作確認



Oracle Backend for FirebaseのLiveLabsで作成したWebアプリケーションを使用します。

Storageの構成が変わると、Webアプリケーションの構成ファイルの内容も変わります。

Project settingsのApplicationのSDK setup and configurationを確認します。

構成のJSONドキュメントの属性objs_typeocistorage_bucketがfusabase-storageに変更されています。


Webアプリケーションのfusabase-config.jsの内容を更新します。


以上で、WebアプリケーションもOCI Object Storageを使用するように切り替わりました。

Webアプリケーションから写真付きのレシピを作成してみます。

LiveLabsのLab 6: Photo Upload, Task 3: Upload a photo and verifyの操作です。


レシピが登録され、写真も表示されます。


画像がどこから取得されているか確認します。

パスタの画像の上でコンテキスト・メニューを開き、画像アドレスをコピーを実行します。


コピーしたアドレスを確認すると、以下のようにOCI Object Storageから直接画像をダウンロードしていることがわかります。このURLは、Object Storageにアップロードされたファイルlemon-herb-pasta.pngの事前承認済リクエストのURLです。

https://namespace.objectstorage.ca-toronto-1.oci.customer-oci.com/p/6lGeRgyKCYbAe5pXAnEQAUG3UjmnA40G2g0-z4QzxIhcUJopxg3pW06S8CCmxURJ/n/yz2dlxjrvfsc/b/fusabase-storage/o/recipes/58FEF49CF3EA7CCAE063020012ACA499/lemon-herb-pasta.png

バケットfusabase-storageの可視性はプライベートなので、少なくてもオブジェクトのリード権限を持つユーザーで認証していないとファイルは読めません。事前承認済リクエストを生成することにより、認証無しでファイルにアクセスできるようにしています。

写真ファイルのアップロードは、scripts/app.jsの以下のコードで行われています。
        const photoFile = fd.get("photo");
        if (photoFile && photoFile.size > 0) {
          // ── Lab 6 TODO: Upload the new recipe photo with the SDK ──
          // After adding this code, remove hidden from #modalPhotoField in index.html.
          
          storage = getStorage(app);
          const photoRef = ref(storage, `recipes/${recipeRef.id}/${photoFile.name}`);
          await uploadBytes(photoRef, photoFile, { contentType: photoFile.type || "image/png" });
          const photoURL = await getDownloadURL(photoRef);
          await updateDoc(doc(collection(db, "recipes"), recipeRef.id), { photoURL });
        }
        el.recipeForm.reset();
        closeModal();
        await refreshRecipes(recipeRef.id);
      });
uploadBytesで写真をStorageに保存(StorageがOCI Object Storageの場合、Object Storageへのアップロード)した後、getDownloadURLを呼び出してアップロードされたオブジェクトの事前承認済リクエストのURLを取得しています。

このURLは、コレクションrecipesのドキュメントにphotoURLとして保存されます。

Oracle Backend for FirebaseのコンソールのDatabaseより、該当のドキュメントのphotoURLを確認すると、アップロードされたオブジェクトの事前承認済リクエストのURLが保持されていることが確認できます。


事前承認済みリクエストには有効期限があります。

Oracle Cloudのコンソールよりバケットfusabase-storage管理を開きます。

管理のページには作成済みの事前承認済リクエストが一覧されます。この一覧に、先ほどアップロードした写真の事前承認済リクエストも含まれています。

写真の事前承認済リクエストの詳細を表示し、有効期限を確認します。


有効期限は、生成日から7日間のようです。


つまり、コレクションrecipesのドキュメントにphotoURLとして保存されているURLは、生成した7日後には無効(404 Not Foundが発生する)なURLになります。

事前承認済リクエストを自動で更新する機能があるとは思えないため、photoURLの生成日のタイプスタンプも保存し、画面に写真を表示するためにphotoURLが取り出される際には有効期限を超えてないかどうか確認し、超えていたらgetDownloadURLを発行してphotoURLを置き換えるといった処理をWebアプリケーションに組み込む必要があるかもしれません。アプリケーションに組み込むのが大変な場合は、バッチ処理で更新するなども対応も考えられます。

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

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を完走できました。

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