Last active
July 3, 2025 13:23
-
-
Save WolfgangSenff/11978dff61f5042ded3c7dff420f7dd6 to your computer and use it in GitHub Desktop.
Test new Firestore document listeners
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
## @meta-authors Kyle Szklenski | |
## @meta-version 2.2 | |
## A reference to a Firestore Document. | |
## Documentation TODO. | |
@tool | |
class_name FirestoreDocument | |
extends Node | |
# A FirestoreDocument objects that holds all important values for a Firestore Document, | |
# @doc_name = name of the Firestore Document, which is the request PATH | |
# @doc_fields = fields held by Firestore Document, in APIs format | |
# created when requested from a `collection().get()` call | |
var document : Dictionary # the Document itself | |
var doc_name : String # only .name | |
var create_time : String # createTime | |
var collection_name : String # Name of the collection to which it belongs | |
var _transforms : FieldTransformArray # The transforms to apply | |
signal changed(changes) | |
func _init(doc : Dictionary = {}): | |
_transforms = FieldTransformArray.new() | |
if doc.has("fields"): | |
document = doc.fields | |
if doc.has("name"): | |
doc_name = doc.name | |
if doc_name.count("/") > 2: | |
doc_name = (doc_name.split("/") as Array).back() | |
if doc.has("createTime"): | |
self.create_time = doc.createTime | |
func replace(with : FirestoreDocument, is_listener := false) -> void: | |
var current = document.duplicate() | |
document = with.document | |
var changes = { | |
"added": [], "removed": [], "updated": [] | |
} | |
for key in current.keys(): | |
if not document.has(key): | |
changes.removed.push_back({ "key" : key }) | |
else: | |
var new_value = Utilities.from_firebase_type(document[key]) | |
var old_value = Utilities.from_firebase_type(current[key]) | |
if typeof(new_value) != typeof(old_value) or new_value != old_value: | |
if old_value == null: | |
changes.removed.push_back({ "key" : key }) # ?? | |
else: | |
changes.updated.push_back({ "key" : key, "old": old_value, "new" : new_value }) | |
for key in document.keys(): | |
if not current.has(key): | |
changes.added.push_back({ "key" : key, "new" : Utilities.from_firebase_type(document[key]) }) | |
if not (changes.added.is_empty() and changes.removed.is_empty() and changes.updated.is_empty()): | |
_emit_changes(changes) | |
func _emit_changes(changes) -> void: | |
if get_child_count() == 1: | |
var listener = get_child(0) | |
listener.send_change(changes) | |
else: | |
changed.emit(changes) | |
func new_document(base_document: Dictionary) -> void: | |
var current = document.duplicate() | |
document = {} | |
for key in base_document.keys(): | |
document[key] = Utilities.to_firebase_type(key) | |
var changes = { | |
"added": [], "removed": [], "updated": [] | |
} | |
for key in current.keys(): | |
if not document.has(key): | |
changes.removed.push_back({ "key" : key }) | |
else: | |
var new_value = Utilities.from_firebase_type(document[key]) | |
var old_value = Utilities.from_firebase_type(current[key]) | |
if typeof(new_value) != typeof(old_value) or new_value != old_value: | |
if old_value == null: | |
changes.removed.push_back({ "key" : key }) # ?? | |
else: | |
changes.updated.push_back({ "key" : key, "old": old_value, "new" : new_value }) | |
for key in document.keys(): | |
if not current.has(key): | |
changes.added.push_back({ "key" : key, "new" : Utilities.from_firebase_type(document[key]) }) | |
if not (changes.added.is_empty() and changes.removed.is_empty() and changes.updated.is_empty()): | |
_emit_changes(changes) | |
func is_null_value(key) -> bool: | |
return document.has(key) and Utilities.from_firebase_type(document[key]) == null | |
# As of right now, we do not track these with track changes; instead, they'll come back when the document updates from the server. | |
# Until that time, it's expected if you want to track these types of changes that you commit for the transforms and then get the document yourself. | |
func add_field_transform(transform : FieldTransform) -> void: | |
_transforms.push_back(transform) | |
func remove_field_transform(transform : FieldTransform) -> void: | |
_transforms.erase(transform) | |
func clear_field_transforms() -> void: | |
_transforms.transforms.clear() | |
func remove_field(field_path : String) -> void: | |
if document.has(field_path): | |
document[field_path] = Utilities.to_firebase_type(null) | |
var changes = { | |
"added": [], "removed": [], "updated": [] | |
} | |
changes.removed.push_back({ "key" : field_path }) | |
_emit_changes(changes) | |
func _erase(field_path : String) -> void: | |
document.erase(field_path) | |
func add_or_update_field(field_path : String, value : Variant) -> void: | |
var changes = { | |
"added": [], "removed": [], "updated": [] | |
} | |
var existing_value = get_value(field_path) | |
var has_field_path = existing_value != null and not is_null_value(field_path) | |
var converted_value = Utilities.to_firebase_type(value) | |
document[field_path] = converted_value | |
if has_field_path: | |
changes.updated.push_back({ "key" : field_path, "old" : existing_value, "new" : value }) | |
else: | |
changes.added.push_back({ "key" : field_path, "new" : value }) | |
_emit_changes(changes) | |
func on_snapshot(when_called : Callable, poll_time : float = 1.0) -> FirestoreListener.FirestoreListenerConnection: | |
if get_child_count() >= 1: # Only one listener per | |
assert(false, "Multiple listeners not allowed for the same document yet") | |
return | |
var listener = preload("res://addons/godot-firebase/firestore/firestore_listener.tscn").instantiate() | |
add_child(listener) | |
listener.initialize_listener(collection_name, doc_name) | |
listener.owner = self | |
listener.changed.connect(when_called, CONNECT_REFERENCE_COUNTED) | |
var result = listener.enable_connection() | |
return result | |
func get_value(property : StringName) -> Variant: | |
if property == "doc_name": | |
return doc_name | |
elif property == "collection_name": | |
return collection_name | |
elif property == "create_time": | |
return create_time | |
if document.has(property): | |
var result = Utilities.from_firebase_type(document[property]) | |
return result | |
return null | |
func _get(property: StringName) -> Variant: | |
return get_value(property) | |
func _set(property: StringName, value: Variant) -> bool: | |
assert(value != null, "When using the dictionary setter, the value cannot be null; use erase_field instead.") | |
document[property] = Utilities.to_firebase_type(value) | |
return true | |
func get_unsafe_document() -> Dictionary: | |
var result = {} | |
for key in keys(): | |
result[key] = Utilities.from_firebase_type(document[key]) | |
return result | |
func keys(): | |
return document.keys() | |
# Call print(document) to return directly this document formatted | |
func _to_string() -> String: | |
return ("doc_name: {doc_name}, \ndata: {data}, \ncreate_time: {create_time}\n").format( | |
{doc_name = self.doc_name, | |
data = document, | |
create_time = self.create_time}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class_name FirestoreListener | |
extends Node | |
signal changed(changes) | |
var _doc_name: String | |
var _collection: FirestoreCollection | |
var _collection_name: String | |
var _rtdb_ref: FirebaseDatabaseReference | |
var _rtdb_path: String | |
var _last_update_time: float | |
func initialize_listener(collection_name: String, doc_name: String) -> void: | |
_doc_name = doc_name | |
_collection_name = collection_name | |
_collection = Firebase.Firestore.collection(_collection_name) | |
_rtdb_path = "firestore_mirrored_listener_data/%s" % _collection_name | |
_rtdb_ref = Firebase.Database.get_database_reference(_rtdb_path, {}) | |
_last_update_time = Time.get_unix_time_from_system() | |
_rtdb_ref.patch_data_update.connect(_on_data_updated) | |
func send_change(changes) -> void: | |
changes["update_time"] = Time.get_unix_time_from_system() | |
_rtdb_ref.update(_doc_name, changes) | |
func _on_data_updated(data: FirebaseResource) -> void: | |
if data.data.update_time > _last_update_time and data.key == _doc_name: | |
_last_update_time = data.data.update_time | |
var document = await _collection.get_doc(_doc_name, true) | |
var changes = data.data as Dictionary | |
#"added": [], "removed": [], "updated": [] | |
var updates = changes.get("updated", []) | |
var deletes = changes.get("removed", []) | |
var adds = changes.get("added", []) | |
if deletes: | |
for delete in deletes: | |
document.remove_field(delete.key) | |
if adds: | |
for add in adds: | |
document[add.key] = add.new | |
if updates: | |
for update in updates: | |
document[update.key] = update.new | |
changed.emit(changes) | |
func enable_connection() -> FirestoreListenerConnection: | |
return FirestoreListenerConnection.new(self) | |
class FirestoreListenerConnection extends RefCounted: | |
var connection | |
func _init(connection_node): | |
connection = connection_node | |
func stop(): | |
if connection != null and is_instance_valid(connection): | |
connection.free() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Copy/paste the above files over the pre-existing ones that are already in the plugin. | |
To test this, you have to ensure that you're actually running two different app runs. The reason for that is simple: the listeners are stored in the scene tree, and there's only one per document per app run. There's a few simple ways to achieve this, but mainly what I'd do is just have Godot itself run multiple versions of the game at the same time. To do that, I personally do the following: | |
1. In the Godot editor, in your opened-project, go to Game at the top. Hit the 3-dots button on the right of the top menu, then uncheck Embed game on next play. | |
2. Go to Debug -> Customize Run Instances, and a popup will show. | |
3. Click on Enable Multiple Instances and increase the count to 2 or even more; I'd like to test this if possible with more than 2 people, if y'all have a game that would work for that. | |
4. Now just run the game as normal, and it'll bring up the game N many times, where N is the number of instances you told it to run. You can undo the multiple run instances simply by coming back to this popup and unchecking enable multiple instances. | |
Keep in mind, this new version makes changes in the underlying, unsafe Json document, so if you have a reference to the actual Json of that document somewhere, you *will not* get the updates. If you have a reference to the FirestoreDocument itself, then you should be good to go. The API is exactly the same, though you no longer need a polling time (I left the parameter in for now, but I'll probably remove it, since I don't think many people were using this feature anyway). | |
Quick edit: BIG thing I forgot to mention: you have to enable the realtime database for these changes to work, and you have to put these rules for it to succeed: | |
"firestore_mirrored_listener_data": { | |
".read": "auth != null", | |
".write": "auth != null" | |
} | |
Those go into the rules for the realtime database, and it should just work! I do require authentication for the simple reason I don't think anyone should use this without auth. For the above, if you have other rules already in there, you can just add a comma after them and add this at the bottom - it allows multiple rules in it. Also make sure that, once your rtdb is initialized, you put your databaseURL value from your settings into your .env file! Without it, it won't know where to go to update stuff. | |
Good luck, and please either comment here or just let me know on Discord how it worked for you and if it did what you're expecting! I'll be making a multi-instance run today myself for my multiplayer with Firebase video series on YouTube, but y'all probably have stuff that's already ready to test, which is tremendously helpful to me so I can get this checked in and going! |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment