added firebase and rudimentary leaderboard support

This commit is contained in:
derek
2025-04-09 11:19:02 -05:00
parent ce08df66e6
commit 25eb9e725a
121 changed files with 4987 additions and 4 deletions

View File

@@ -0,0 +1,22 @@
extends FirestoreTransform
class_name FieldTransform
enum TransformType { SetToServerValue, Maximum, Minimum, Increment, AppendMissingElements, RemoveAllFromArray }
const transtype_string_map = {
TransformType.SetToServerValue : "setToServerValue",
TransformType.Increment : "increment",
TransformType.Maximum : "maximum",
TransformType.Minimum : "minimum",
TransformType.AppendMissingElements : "appendMissingElements",
TransformType.RemoveAllFromArray : "removeAllFromArray"
}
var document_exists : bool
var document_name : String
var field_path : String
var transform_type : TransformType
var value : Variant
func get_transform_type() -> String:
return transtype_string_map[transform_type]

View File

@@ -0,0 +1 @@
uid://dw7p8101sxern

View File

@@ -0,0 +1,35 @@
class_name FieldTransformArray
extends RefCounted
var transforms = []
var _extended_url
var _collection_name
const _separator = "/"
func set_config(config : Dictionary):
_extended_url = config.extended_url
_collection_name = config.collection_name
func push_back(transform : FieldTransform) -> void:
transforms.push_back(transform)
func serialize() -> Dictionary:
var body = {}
var writes_array = []
for transform in transforms:
writes_array.push_back({
"currentDocument": { "exists" : transform.document_exists },
"transform" : {
"document": _extended_url + _collection_name + _separator + transform.document_name,
"fieldTransforms": [
{
"fieldPath": transform.field_path,
transform.get_transform_type(): transform.value
}]
}
})
body = { "writes": writes_array }
return body

View File

@@ -0,0 +1 @@
uid://bopmc0mi55dvu

View File

@@ -0,0 +1,19 @@
class_name DecrementTransform
extends FieldTransform
func _init(doc_name : String, doc_must_exist : bool, path_to_field : String, by_this_much : Variant) -> void:
document_name = doc_name
document_exists = doc_must_exist
field_path = path_to_field
transform_type = FieldTransform.TransformType.Increment
var value_type = typeof(by_this_much)
if value_type == TYPE_INT:
self.value = {
"integerValue": -by_this_much
}
elif value_type == TYPE_FLOAT:
self.value = {
"doubleValue": -by_this_much
}

View File

@@ -0,0 +1 @@
uid://bit26sxq4daw7

View File

@@ -0,0 +1,19 @@
class_name IncrementTransform
extends FieldTransform
func _init(doc_name : String, doc_must_exist : bool, path_to_field : String, by_this_much : Variant) -> void:
document_name = doc_name
document_exists = doc_must_exist
field_path = path_to_field
transform_type = FieldTransform.TransformType.Increment
var value_type = typeof(by_this_much)
if value_type == TYPE_INT:
self.value = {
"integerValue": by_this_much
}
elif value_type == TYPE_FLOAT:
self.value = {
"doubleValue": by_this_much
}

View File

@@ -0,0 +1 @@
uid://c5gx3a3dsmwop

View File

@@ -0,0 +1,19 @@
class_name MaxTransform
extends FieldTransform
func _init(doc_name : String, doc_must_exist : bool, path_to_field : String, value : Variant) -> void:
document_name = doc_name
document_exists = doc_must_exist
field_path = path_to_field
transform_type = FieldTransform.TransformType.Maximum
var value_type = typeof(value)
if value_type == TYPE_INT:
self.value = {
"integerValue": value
}
elif value_type == TYPE_FLOAT:
self.value = {
"doubleValue": value
}

View File

@@ -0,0 +1 @@
uid://xf5c8b0lrjpl

View File

@@ -0,0 +1,19 @@
class_name MinTransform
extends FieldTransform
func _init(doc_name : String, doc_must_exist : bool, path_to_field : String, value : Variant) -> void:
document_name = doc_name
document_exists = doc_must_exist
field_path = path_to_field
transform_type = FieldTransform.TransformType.Minimum
var value_type = typeof(value)
if value_type == TYPE_INT:
self.value = {
"integerValue": value
}
elif value_type == TYPE_FLOAT:
self.value = {
"doubleValue": value
}

View File

@@ -0,0 +1 @@
uid://cei7mxm5uqrkc

View File

@@ -0,0 +1,10 @@
class_name ServerTimestampTransform
extends FieldTransform
func _init(doc_name : String, doc_must_exist : bool, path_to_field : String) -> void:
document_name = doc_name
document_exists = doc_must_exist
field_path = path_to_field
transform_type = FieldTransform.TransformType.SetToServerValue
value = "REQUEST_TIME"

View File

@@ -0,0 +1 @@
uid://cqkqdex0s16id

View File

@@ -0,0 +1,243 @@
## @meta-authors Nicolò 'fenix' Santilio,
## @meta-version 2.5
##
## Referenced by [code]Firebase.Firestore[/code]. Represents the Firestore module.
## Cloud Firestore is a flexible, scalable database for mobile, web, and server development from Firebase and Google Cloud.
## Like Firebase Realtime Database, it keeps your data in sync across client apps through realtime listeners and offers offline support for mobile and web so you can build responsive apps that work regardless of network latency or Internet connectivity. Cloud Firestore also offers seamless integration with other Firebase and Google Cloud products, including Cloud Functions.
##
## Following Cloud Firestore's NoSQL data model, you store data in [b]documents[/b] that contain fields mapping to values. These documents are stored in [b]collections[/b], which are containers for your documents that you can use to organize your data and build queries.
## Documents support many different data types, from simple strings and numbers, to complex, nested objects. You can also create subcollections within documents and build hierarchical data structures that scale as your database grows.
## The Cloud Firestore data model supports whatever data structure works best for your app.
##
## (source: [url=https://firebase.google.com/docs/firestore]Firestore[/url])
##
## @tutorial https://github.com/GodotNuts/GodotFirebase/wiki/Firestore
@tool
class_name FirebaseFirestore
extends Node
const _API_VERSION : String = "v1"
## Emitted when a [code]list()[/code] or [code]query()[/code] request is [b]not[/b] successfully completed.
signal error(code, status, message)
enum Requests {
NONE = -1, ## Firestore is not processing any request.
LIST, ## Firestore is processing a [code]list()[/code] request checked a collection.
QUERY ## Firestore is processing a [code]query()[/code] request checked a collection.
}
# TODO: Implement cache size limit
const CACHE_SIZE_UNLIMITED = -1
const _CACHE_EXTENSION : String = ".fscache"
const _CACHE_RECORD_FILE : String = "RmlyZXN0b3JlIGNhY2hlLXJlY29yZHMu.fscache"
const _AUTHORIZATION_HEADER : String = "Authorization: Bearer "
const _MAX_POOLED_REQUEST_AGE = 30
## The code indicating the request Firestore is processing.
## See @[enum FirebaseFirestore.Requests] to get a full list of codes identifiers.
## @enum Requests
var request: int = -1
## A Dictionary containing all authentication fields for the current logged user.
## @type Dictionary
var auth: Dictionary
var _config: Dictionary = {}
var _cache_loc: String
var _encrypt_key := "5vg76n90345f7w390346" if Utilities.is_web() else OS.get_unique_id()
var _base_url: String = ""
var _extended_url: String = "projects/[PROJECT_ID]/databases/(default)/documents/"
var _query_suffix: String = ":runQuery"
var _agg_query_suffix: String = ":runAggregationQuery"
#var _connect_check_node : HTTPRequest
var _request_list_node: HTTPRequest
var _requests_queue: Array = []
var _current_query: FirestoreQuery
## Returns a reference collection by its [i]path[/i].
##
## The returned object will be of [code]FirestoreCollection[/code] type.
## If saved into a variable, it can be used to issue requests checked the collection itself.
## @args path
## @return FirestoreCollection
func collection(path : String) -> FirestoreCollection:
for coll in get_children():
if coll is FirestoreCollection:
if coll.collection_name == path:
return coll
var coll : FirestoreCollection = FirestoreCollection.new()
coll._extended_url = _extended_url
coll._base_url = _base_url
coll._config = _config
coll.auth = auth
coll.collection_name = path
add_child(coll)
return coll
## Issue a query checked your Firestore database.
##
## [b]Note:[/b] a [code]FirestoreQuery[/code] object needs to be created to issue the query.
## When awaited, this function returns the resulting array from the query.
##
## ex.
## [code]var query_results = await Firebase.Firestore.query(FirestoreQuery.new())[/code]
##
## [b]Warning:[/b] It currently does not work offline!
##
## @args query
## @arg-types FirestoreQuery
## @return Array[FirestoreDocument]
func query(query : FirestoreQuery) -> Array:
if query.aggregations.size() > 0:
Firebase._printerr("Aggregation query sent with normal query call: " + str(query))
return []
var task : FirestoreTask = FirestoreTask.new()
task.action = FirestoreTask.Task.TASK_QUERY
var body: Dictionary = { structuredQuery = query.query }
var url: String = _base_url + _extended_url + query.sub_collection_path + _query_suffix
task.data = query
task._fields = JSON.stringify(body)
task._url = url
_pooled_request(task)
return await _handle_task_finished(task)
## Issue an aggregation query (sum, average, count) against your Firestore database;
## cheaper than a normal query and counting (for instance) values directly.
##
## [b]Note:[/b] a [code]FirestoreQuery[/code] object needs to be created to issue the query.
## When awaited, this function returns the result from the aggregation query.
##
## ex.
## [code]var query_results = await Firebase.Firestore.query(FirestoreQuery.new())[/code]
##
## [b]Warning:[/b] It currently does not work offline!
##
## @args query
## @arg-types FirestoreQuery
## @return Variant representing the array results of the aggregation query
func aggregation_query(query : FirestoreQuery) -> Variant:
if query.aggregations.size() == 0:
Firebase._printerr("Aggregation query sent with no aggregation values: " + str(query))
return 0
var task : FirestoreTask = FirestoreTask.new()
task.action = FirestoreTask.Task.TASK_AGG_QUERY
var body: Dictionary = { structuredAggregationQuery = { structuredQuery = query.query, aggregations = query.aggregations } }
var url: String = _base_url + _extended_url + _agg_query_suffix
task.data = query
task._fields = JSON.stringify(body)
task._url = url
_pooled_request(task)
var result = await _handle_task_finished(task)
return result
## Request a list of contents (documents and/or collections) inside a collection, specified by its [i]id[/i]. This method will return an Array[FirestoreDocument]
## @args collection_id, page_size, page_token, order_by
## @arg-types String, int, String, String
## @arg-defaults , 0, "", ""
## @return Array[FirestoreDocument]
func list(path : String = "", page_size : int = 0, page_token : String = "", order_by : String = "") -> Array:
var task : FirestoreTask = FirestoreTask.new()
task.action = FirestoreTask.Task.TASK_LIST
var url : String = _base_url + _extended_url + path
if page_size != 0:
url+="?pageSize="+str(page_size)
if page_token != "":
url+="&pageToken="+page_token
if order_by != "":
url+="&orderBy="+order_by
task.data = [path, page_size, page_token, order_by]
task._url = url
_pooled_request(task)
return await _handle_task_finished(task)
func _set_config(config_json : Dictionary) -> void:
_config = config_json
_cache_loc = _config["cacheLocation"]
_extended_url = _extended_url.replace("[PROJECT_ID]", _config.projectId)
# Since caching is causing a lot of issues, I'm removing this check for now. We will revisit this in the future, once we have some time to investigate why the cache is being corrupted.
_check_emulating()
func _check_emulating() -> void :
## Check emulating
if not Firebase.emulating:
_base_url = "https://firestore.googleapis.com/{version}/".format({ version = _API_VERSION })
else:
var port : String = _config.emulators.ports.firestore
if port == "":
Firebase._printerr("You are in 'emulated' mode, but the port for Firestore has not been configured.")
else:
_base_url = "http://localhost:{port}/{version}/".format({ version = _API_VERSION, port = port })
func _pooled_request(task : FirestoreTask) -> void:
if (auth == null or auth.is_empty()) and not Firebase.emulating:
Firebase._print("Unauthenticated request issued...")
Firebase.Auth.login_anonymous()
var result : Array = await Firebase.Auth.auth_request
if result[0] != 1:
_check_auth_error(result[0], result[1])
Firebase._print("Client connected as Anonymous")
if not Firebase.emulating:
task._headers = PackedStringArray([_AUTHORIZATION_HEADER + auth.idtoken])
var http_request = HTTPRequest.new()
http_request.timeout = 5
Utilities.fix_http_request(http_request)
add_child(http_request)
http_request.request_completed.connect(
func(result, response_code, headers, body):
task._on_request_completed(result, response_code, headers, body)
http_request.queue_free()
)
http_request.request(task._url, task._headers, task._method, task._fields)
func _on_FirebaseAuth_login_succeeded(auth_result : Dictionary) -> void:
auth = auth_result
for coll in get_children():
if coll is FirestoreCollection:
coll.auth = auth
func _on_FirebaseAuth_token_refresh_succeeded(auth_result : Dictionary) -> void:
auth = auth_result
for coll in get_children():
if coll is FirestoreCollection:
coll.auth = auth
func _on_FirebaseAuth_logout() -> void:
auth = {}
func _check_auth_error(code : int, message : String) -> void:
var err : String
match code:
400: err = "Please enable the Anonymous Sign-in method, or Authenticate the Client before issuing a request"
Firebase._printerr(err)
Firebase._printerr(message)
func _handle_task_finished(task : FirestoreTask):
await task.task_finished
if task.error.keys().size() > 0:
error.emit(task.error)
return task.data

View File

@@ -0,0 +1 @@
uid://c2n2dkjpnwcsd

View File

@@ -0,0 +1,178 @@
## @meta-authors TODO
## @meta-authors TODO
## @meta-version 2.3
## A reference to a Firestore Collection.
## Documentation TODO.
@tool
class_name FirestoreCollection
extends Node
signal error(error_result)
const _AUTHORIZATION_HEADER : String = "Authorization: Bearer "
const _separator : String = "/"
const _query_tag : String = "?"
const _documentId_tag : String = "documentId="
var auth : Dictionary
var collection_name : String
var _base_url : String
var _extended_url : String
var _config : Dictionary
var _documents := {}
# ----------------------- Requests
## @args document_id
## @return FirestoreTask
## used to GET a document from the collection, specify @document_id
func get_doc(document_id : String, from_cache : bool = false, is_listener : bool = false) -> FirestoreDocument:
if from_cache:
# for now, just return the child directly; in the future, make it smarter so there's a default, if long, polling time for this
for child in get_children():
if child.doc_name == document_id:
return child
var task : FirestoreTask = FirestoreTask.new()
task.action = FirestoreTask.Task.TASK_GET
task.data = collection_name + "/" + document_id
var url = _get_request_url() + _separator + document_id.replace(" ", "%20")
_process_request(task, document_id, url)
var result = await Firebase.Firestore._handle_task_finished(task)
if result != null:
for child in get_children():
if child.doc_name == document_id:
child.replace(result, true)
result = child
break
else:
print("get_document returned null for %s %s" % [collection_name, document_id])
return result
## @args document_id, fields
## @arg-defaults , {}
## @return FirestoreDocument
## used to ADD a new document to the collection, specify @documentID and @data
func add(document_id : String, data : Dictionary = {}) -> FirestoreDocument:
var task : FirestoreTask = FirestoreTask.new()
task.action = FirestoreTask.Task.TASK_POST
task.data = collection_name + "/" + document_id
var url = _get_request_url() + _query_tag + _documentId_tag + document_id
_process_request(task, document_id, url, JSON.stringify(Utilities.dict2fields(data)))
var result = await Firebase.Firestore._handle_task_finished(task)
if result != null:
for child in get_children():
if child.doc_name == document_id:
child.free() # Consider throwing an error for this since it shouldn't already exist
break
result.collection_name = collection_name
add_child(result, true)
return result
## @args document
## @return FirestoreDocument
# used to UPDATE a document, specify the document
func update(document : FirestoreDocument) -> FirestoreDocument:
var task : FirestoreTask = FirestoreTask.new()
task.action = FirestoreTask.Task.TASK_PATCH
task.data = collection_name + "/" + document.doc_name
var url = _get_request_url() + _separator + document.doc_name.replace(" ", "%20") + "?"
for key in document.keys():
url+="updateMask.fieldPaths={key}&".format({key = key})
url = url.rstrip("&")
for key in document.keys():
if document.get_value(key) == null:
document._erase(key)
var temp_transforms
if document._transforms != null:
temp_transforms = document._transforms
document._transforms = null
var body = JSON.stringify({"fields": document.document})
_process_request(task, document.doc_name, url, body)
var result = await Firebase.Firestore._handle_task_finished(task)
if result != null:
for child in get_children():
if child.doc_name == result.doc_name:
child.replace(result, true)
break
if temp_transforms != null:
result._transforms = temp_transforms
return result
## @args document
## @return Dictionary
# Used to commit changes from transforms, specify the document with the transforms
func commit(document : FirestoreDocument) -> Dictionary:
var task : FirestoreTask = FirestoreTask.new()
task.action = FirestoreTask.Task.TASK_COMMIT
var url = get_database_url("commit")
document._transforms.set_config(
{
"extended_url": _extended_url,
"collection_name": collection_name
}
) # Only place we can set this is here, oofness
var body = document._transforms.serialize()
document.clear_field_transforms()
_process_request(task, document.doc_name, url, JSON.stringify(body))
return await Firebase.Firestore._handle_task_finished(task) # Not implementing the follow-up get here as user may have a listener that's already listening for changes, but user should call get if they don't
## @args document_id
## @return FirestoreTask
# used to DELETE a document, specify the document
func delete(document : FirestoreDocument) -> bool:
var doc_name = document.doc_name
var task : FirestoreTask = FirestoreTask.new()
task.action = FirestoreTask.Task.TASK_DELETE
task.data = document.collection_name + "/" + doc_name
var url = _get_request_url() + _separator + doc_name.replace(" ", "%20")
_process_request(task, doc_name, url)
var result = await Firebase.Firestore._handle_task_finished(task)
# Clean up the cache
if result:
for node in get_children():
if node.doc_name == doc_name:
node.free() # Should be only one
break
return result
func _get_request_url() -> String:
return _base_url + _extended_url + collection_name
func _process_request(task : FirestoreTask, document_id : String, url : String, fields := "") -> void:
if auth == null or auth.is_empty():
Firebase._print("Unauthenticated request issued...")
Firebase.Auth.login_anonymous()
var result : Array = await Firebase.Auth.auth_request
if result[0] != 1:
Firebase.Firestore._check_auth_error(result[0], result[1])
return
Firebase._print("Client authenticated as Anonymous User.")
task._url = url
task._fields = fields
task._headers = PackedStringArray([_AUTHORIZATION_HEADER + auth.idtoken])
Firebase.Firestore._pooled_request(task)
func get_database_url(append) -> String:
return _base_url + _extended_url.rstrip("/") + ":" + append

View File

@@ -0,0 +1 @@
uid://bnvqqpj5cima0

View File

@@ -0,0 +1,185 @@
## @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": [], "is_listener": is_listener
}
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()):
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": [], "is_listener": false
}
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()):
changed.emit(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": [], "is_listener": false
}
changes.removed.push_back({ "key" : field_path })
changed.emit(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": [], "is_listener": false
}
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 })
changed.emit(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
changed.connect(when_called, CONNECT_REFERENCE_COUNTED)
var listener = preload("res://addons/godot-firebase/firestore/firestore_listener.tscn").instantiate()
add_child(listener)
listener.initialize_listener(collection_name, doc_name, poll_time)
listener.owner = self
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})

View File

@@ -0,0 +1 @@
uid://lvx6e1rnbjha

View File

@@ -0,0 +1,47 @@
class_name FirestoreListener
extends Node
const MinPollTime = 60 * 2 # seconds, so 2 minutes
var _doc_name : String
var _poll_time : float
var _collection : FirestoreCollection
var _total_time = 0.0
var _enabled := false
func initialize_listener(collection_name : String, doc_name : String, poll_time : float) -> void:
_poll_time = max(poll_time, MinPollTime)
_doc_name = doc_name
_collection = Firebase.Firestore.collection(collection_name)
func enable_connection() -> FirestoreListenerConnection:
_enabled = true
set_process(true)
return FirestoreListenerConnection.new(self)
func _process(delta: float) -> void:
if _enabled:
_total_time += delta
if _total_time >= _poll_time:
_check_for_server_updates()
_total_time = 0.0
func _check_for_server_updates() -> void:
var executor = func():
var doc = await _collection.get_doc(_doc_name, false, true)
if doc == null:
set_process(false) # Document was deleted out from under us, so stop updating
executor.call() # Hack to work around the await here, otherwise would have to call with await in _process and that's no bueno
class FirestoreListenerConnection extends RefCounted:
var connection
func _init(connection_node):
connection = connection_node
func stop():
if connection != null and is_instance_valid(connection):
connection.set_process(false)
connection.free()

View File

@@ -0,0 +1 @@
uid://bnn8dx3q452pr

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://bwv7vtgssc0n5"]
[ext_resource type="Script" path="res://addons/godot-firebase/firestore/firestore_listener.gd" id="1_qlaei"]
[node name="FirestoreListener" type="Node"]
script = ExtResource("1_qlaei")

View File

@@ -0,0 +1,255 @@
## @meta-authors Nicoló 'fenix' Santilio, Kyle Szklenski
## @meta-version 1.4
## A firestore query.
## Documentation TODO.
@tool
extends RefCounted
class_name FirestoreQuery
class Order:
var obj: Dictionary
class Cursor:
var values: Array
var before: bool
func _init(v : Array,b : bool):
values = v
before = b
signal query_result(query_result)
const TEMPLATE_QUERY: Dictionary = {
select = {},
from = [],
where = {},
orderBy = [],
startAt = {},
endAt = {},
offset = 0,
limit = 0
}
var query: Dictionary = {}
var aggregations: Array[Dictionary] = []
var sub_collection_path: String = ""
enum OPERATOR {
# Standard operators
OPERATOR_UNSPECIFIED,
LESS_THAN,
LESS_THAN_OR_EQUAL,
GREATER_THAN,
GREATER_THAN_OR_EQUAL,
EQUAL,
NOT_EQUAL,
ARRAY_CONTAINS,
ARRAY_CONTAINS_ANY,
IN,
NOT_IN,
# Unary operators
IS_NAN,
IS_NULL,
IS_NOT_NAN,
IS_NOT_NULL,
# Complex operators
AND,
OR
}
enum DIRECTION {
DIRECTION_UNSPECIFIED,
ASCENDING,
DESCENDING
}
# Select which fields you want to return as a reflection from your query.
# Fields must be added inside a list. Only a field is accepted inside the list
# Leave the Array empty if you want to return the whole document
func select(fields) -> FirestoreQuery:
match typeof(fields):
TYPE_STRING:
query["select"] = { fields = { fieldPath = fields } }
TYPE_ARRAY:
for field in fields:
field = ({ fieldPath = field })
query["select"] = { fields = fields }
_:
print("Type of 'fields' is not accepted.")
return self
# Select the collection you want to return the query result from
# if @all_descendants also sub-collections will be returned. If false, only documents will be returned
func from(collection_id : String, all_descendants : bool = true) -> FirestoreQuery:
query["from"] = [{collectionId = collection_id, allDescendants = all_descendants}]
return self
# @collections_array MUST be an Array of Arrays with this structure
# [ ["collection_id", true/false] ]
func from_many(collections_array : Array) -> FirestoreQuery:
var collections : Array = []
for collection in collections_array:
collections.append({collectionId = collection[0], allDescendants = collection[1]})
query["from"] = collections.duplicate(true)
return self
# Query the value of a field you want to match
# @field : the name of the field
# @operator : from FirestoreQuery.OPERATOR
# @value : can be any type - String, int, bool, float
# @chain : from FirestoreQuery.OPERATOR.[OR/AND], use it only if you want to chain "AND" or "OR" logic with futher where() calls
# eg. super.where("name", OPERATOR.EQUAL, "Matt", OPERATOR.AND).where("age", OPERATOR.LESS_THAN, 20)
func where(field : String, operator : int, value = null, chain : int = -1):
if operator in [OPERATOR.IS_NAN, OPERATOR.IS_NULL, OPERATOR.IS_NOT_NAN, OPERATOR.IS_NOT_NULL]:
if (chain in [OPERATOR.AND, OPERATOR.OR]) or (query.has("where") and query.where.has("compositeFilter")):
var filters : Array = []
if query.has("where") and query.where.has("compositeFilter"):
if chain == -1:
filters = query.where.compositeFilter.filters.duplicate(true)
chain = OPERATOR.get(query.where.compositeFilter.op)
else:
filters.append(query.where)
filters.append(create_unary_filter(field, operator))
query["where"] = create_composite_filter(chain, filters)
else:
query["where"] = create_unary_filter(field, operator)
else:
if value == null:
print("A value must be defined to match the field: {field}".format({field = field}))
else:
if (chain in [OPERATOR.AND, OPERATOR.OR]) or (query.has("where") and query.where.has("compositeFilter")):
var filters : Array = []
if query.has("where") and query.where.has("compositeFilter"):
if chain == -1:
filters = query.where.compositeFilter.filters.duplicate(true)
chain = OPERATOR.get(query.where.compositeFilter.op)
else:
filters.append(query.where)
filters.append(create_field_filter(field, operator, value))
query["where"] = create_composite_filter(chain, filters)
else:
query["where"] = create_field_filter(field, operator, value)
return self
# Order by a field, defining its name and the order direction
# default directoin = Ascending
func order_by(field : String, direction : int = DIRECTION.ASCENDING) -> FirestoreQuery:
query["orderBy"] = [_order_object(field, direction).obj]
return self
# Order by a set of fields and directions
# @order_list is an Array of Arrays with the following structure
# [@field_name , @DIRECTION.[direction]]
# else, order_object() can be called to return an already parsed Dictionary
func order_by_fields(order_field_list : Array) -> FirestoreQuery:
var order_list : Array = []
for order in order_field_list:
if order is Array:
order_list.append(_order_object(order[0], order[1]).obj)
elif order is Order:
order_list.append(order.obj)
query["orderBy"] = order_list
return self
func start_at(value, before : bool) -> FirestoreQuery:
var cursor : Cursor = _cursor_object(value, before)
query["startAt"] = { values = cursor.values, before = cursor.before }
print(query["startAt"])
return self
func end_at(value, before : bool) -> FirestoreQuery:
var cursor : Cursor = _cursor_object(value, before)
query["startAt"] = { values = cursor.values, before = cursor.before }
print(query["startAt"])
return self
func offset(offset : int) -> FirestoreQuery:
if offset < 0:
print("If specified, offset must be >= 0")
else:
query["offset"] = offset
return self
func limit(limit : int) -> FirestoreQuery:
if limit < 0:
print("If specified, offset must be >= 0")
else:
query["limit"] = limit
return self
func aggregate() -> FirestoreAggregation:
return FirestoreAggregation.new(self)
class FirestoreAggregation extends RefCounted:
var _query: FirestoreQuery
func _init(query: FirestoreQuery) -> void:
_query = query
func sum(field: String) -> FirestoreQuery:
_query.aggregations.push_back({ sum = { field = { fieldPath = field }}})
return _query
func count(up_to: int) -> FirestoreQuery:
_query.aggregations.push_back({ count = { upTo = up_to }})
return _query
func average(field: String) -> FirestoreQuery:
_query.aggregations.push_back({ avg = { field = { fieldPath = field }}})
return _query
# UTILITIES ----------------------------------------
static func _cursor_object(value, before : bool) -> Cursor:
var parse : Dictionary = Utilities.dict2fields({value = value}).fields.value
var cursor : Cursor = Cursor.new(parse.arrayValue.values if parse.has("arrayValue") else [parse], before)
return cursor
static func _order_object(field : String, direction : int) -> Order:
var order : Order = Order.new()
order.obj = { field = { fieldPath = field }, direction = DIRECTION.keys()[direction] }
return order
func create_field_filter(field : String, operator : int, value) -> Dictionary:
return {
fieldFilter = {
field = { fieldPath = field },
op = OPERATOR.keys()[operator],
value = Utilities.dict2fields({value = value}).fields.value
} }
func create_unary_filter(field : String, operator : int) -> Dictionary:
return {
unaryFilter = {
field = { fieldPath = field },
op = OPERATOR.keys()[operator],
} }
func create_composite_filter(operator : int, filters : Array) -> Dictionary:
return {
compositeFilter = {
op = OPERATOR.keys()[operator],
filters = filters
} }
func clean() -> void:
query = { }
func _to_string() -> String:
var pretty : String = "QUERY:\n"
for key in query.keys():
pretty += "- {key} = {value}\n".format({key = key, value = query.get(key)})
return pretty

View File

@@ -0,0 +1 @@
uid://c7laxjcm52kh5

View File

@@ -0,0 +1,188 @@
## @meta-authors Nicolò 'fenix' Santilio, Kyle 'backat50ft' Szklenski
## @meta-version 1.4
##
## A [code]FirestoreTask[/code] is an independent node inheriting [code]HTTPRequest[/code] that processes a [code]Firestore[/code] request.
## Once the Task is completed (both if successfully or not) it will emit the relative signal (or a general purpose signal [code]task_finished()[/code]) and will destroy automatically.
##
## Being a [code]Node[/code] it can be stored in a variable to yield checked it, and receive its result as a callback.
## All signals emitted by a [code]FirestoreTask[/code] represent a direct level of signal communication, which can be high ([code]get_document(document), result_query(result)[/code]) or low ([code]task_finished(result)[/code]).
## An indirect level of communication with Tasks is also provided, redirecting signals to the [class FirebaseFirestore] module.
##
## ex.
## [code]var task : FirestoreTask = Firebase.Firestore.query(query)[/code]
## [code]var result : Array = await task.task_finished[/code]
## [code]var result : Array = await task.result_query[/code]
## [code]var result : Array = await Firebase.Firestore.task_finished[/code]
## [code]var result : Array = await Firebase.Firestore.result_query[/code]
##
## @tutorial https://github.com/GodotNuts/GodotFirebase/wiki/Firestore#FirestoreTask
@tool
class_name FirestoreTask
extends RefCounted
## Emitted when a request is completed. The request can be successful or not successful: if not, an [code]error[/code] Dictionary will be passed as a result.
## @arg-types Variant
signal task_finished()
enum Task {
TASK_GET, ## A GET Request Task, processing a get() request
TASK_POST, ## A POST Request Task, processing add() request
TASK_PATCH, ## A PATCH Request Task, processing a update() request
TASK_DELETE, ## A DELETE Request Task, processing a delete() request
TASK_QUERY, ## A POST Request Task, processing a query() request
TASK_AGG_QUERY, ## A POST Request Task, processing an aggregation_query() request
TASK_LIST, ## A POST Request Task, processing a list() request
TASK_COMMIT ## A POST Request Task that hits the write api
}
## Mapping of Task enum values to descriptions for use in printing user-friendly error codes.
const TASK_MAP = {
Task.TASK_GET: "GET DOCUMENT",
Task.TASK_POST: "ADD DOCUMENT",
Task.TASK_PATCH: "UPDATE DOCUMENT",
Task.TASK_DELETE: "DELETE DOCUMENT",
Task.TASK_QUERY: "QUERY COLLECTION",
Task.TASK_LIST: "LIST DOCUMENTS",
Task.TASK_COMMIT: "COMMIT DOCUMENT",
Task.TASK_AGG_QUERY: "AGG QUERY COLLECTION"
}
## The code indicating the request Firestore is processing.
## See @[enum FirebaseFirestore.Requests] to get a full list of codes identifiers.
## @setter set_action
var action : int = -1 : set = set_action
## A variable, temporary holding the result of the request.
var data
var error: Dictionary
var document: FirestoreDocument
var _response_headers: PackedStringArray = PackedStringArray()
var _response_code: int = 0
var _method: int = -1
var _url: String = ""
var _fields: String = ""
var _headers: PackedStringArray = []
func _on_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
var bod = body.get_string_from_utf8()
if bod != "":
bod = Utilities.get_json_data(bod)
var failed: bool = bod is Dictionary and bod.has("error") and response_code != HTTPClient.RESPONSE_OK
# Probably going to regret this...
if response_code == HTTPClient.RESPONSE_OK:
match action:
Task.TASK_POST, Task.TASK_GET, Task.TASK_PATCH:
document = FirestoreDocument.new(bod)
data = document
Task.TASK_DELETE:
data = true
Task.TASK_QUERY:
data = []
for doc in bod:
if doc.has('document'):
data.append(FirestoreDocument.new(doc.document))
Task.TASK_AGG_QUERY:
var agg_results = []
for agg_result in bod:
var idx = 0
var query_results = {}
for field_value in agg_result.result.aggregateFields.keys():
var agg = data.aggregations[idx]
var field = agg_result.result.aggregateFields[field_value]
query_results[agg.keys()[0]] = Utilities.from_firebase_type(field)
idx += 1
agg_results.push_back(query_results)
data = agg_results
Task.TASK_LIST:
data = []
if bod.has('documents'):
for doc in bod.documents:
data.append(FirestoreDocument.new(doc))
if bod.has("nextPageToken"):
data.append(bod.nextPageToken)
Task.TASK_COMMIT:
data = bod # Commit's response is not a full document, so don't treat it as such
else:
var description = ""
if TASK_MAP.has(action):
description = "(" + TASK_MAP[action] + ")"
Firebase._printerr("Action in error was: " + str(action) + " " + description)
build_error(bod, action, description)
task_finished.emit()
func build_error(_error, action, description) -> void:
if _error:
if _error is Array and _error.size() > 0 and _error[0].has("error"):
_error = _error[0].error
elif _error is Dictionary and _error.keys().size() > 0 and _error.has("error"):
_error = _error.error
error = _error
else:
#error.code, error.status, error.message
error = { "error": {
"code": 0,
"status": "Unknown Error",
"message": "Error: %s - %s" % [action, description]
}
}
data = null
func set_action(value : int) -> void:
action = value
match action:
Task.TASK_GET, Task.TASK_LIST:
_method = HTTPClient.METHOD_GET
Task.TASK_POST, Task.TASK_QUERY, Task.TASK_AGG_QUERY:
_method = HTTPClient.METHOD_POST
Task.TASK_PATCH:
_method = HTTPClient.METHOD_PATCH
Task.TASK_DELETE:
_method = HTTPClient.METHOD_DELETE
Task.TASK_COMMIT:
_method = HTTPClient.METHOD_POST
_:
assert(false)
func _merge_dict(dic_a : Dictionary, dic_b : Dictionary, nullify := false) -> Dictionary:
var ret := dic_a.duplicate(true)
for key in dic_b:
var val = dic_b[key]
if val == null and nullify:
ret.erase(key)
elif val is Array:
ret[key] = _merge_array(ret.get(key) if ret.get(key) else [], val)
elif val is Dictionary:
ret[key] = _merge_dict(ret.get(key) if ret.get(key) else {}, val)
else:
ret[key] = val
return ret
func _merge_array(arr_a : Array, arr_b : Array, nullify := false) -> Array:
var ret := arr_a.duplicate(true)
ret.resize(len(arr_b))
var deletions := 0
for i in len(arr_b):
var index : int = i - deletions
var val = arr_b[index]
if val == null and nullify:
ret.remove_at(index)
deletions += i
elif val is Array:
ret[index] = _merge_array(ret[index] if ret[index] else [], val)
elif val is Dictionary:
ret[index] = _merge_dict(ret[index] if ret[index] else {}, val)
else:
ret[index] = val
return ret

View File

@@ -0,0 +1 @@
uid://bacq4pvag0wii

View File

@@ -0,0 +1,3 @@
class_name FirestoreTransform
extends RefCounted

View File

@@ -0,0 +1 @@
uid://pybqqmlkru0q