From 19bc818e5a3bed4d58294d314fbfca27466ebdbd Mon Sep 17 00:00:00 2001 From: Kyle Szklenski Date: Tue, 4 Jun 2024 08:16:25 -0400 Subject: [PATCH] Massive Firestore refactor --- addons/godot-firebase/Utilities.gd | 191 ++++++++++++++ addons/godot-firebase/firestore/firestore.gd | 164 +++++------- .../firestore/firestore_collection.gd | 174 +++++++------ .../firestore/firestore_document.gd | 238 ++++++++---------- .../firestore/firestore_listener.gd | 47 ++++ .../firestore/firestore_listener.tscn | 6 + .../firestore/firestore_query.gd | 4 +- .../firestore/firestore_task.gd | 105 ++------ addons/godot-firebase/functions/functions.gd | 3 +- 9 files changed, 527 insertions(+), 405 deletions(-) create mode 100644 addons/godot-firebase/firestore/firestore_listener.gd create mode 100644 addons/godot-firebase/firestore/firestore_listener.tscn diff --git a/addons/godot-firebase/Utilities.gd b/addons/godot-firebase/Utilities.gd index 3000511..ed26a71 100644 --- a/addons/godot-firebase/Utilities.gd +++ b/addons/godot-firebase/Utilities.gd @@ -12,6 +12,189 @@ static func get_json_data(value): return null +# Pass a dictionary { 'key' : 'value' } to format it in a APIs usable .fields +# Field Path3D using the "dot" (`.`) notation are supported: +# ex. { "PATH.TO.SUBKEY" : "VALUE" } ==> { "PATH" : { "TO" : { "SUBKEY" : "VALUE" } } } +static func dict2fields(dict : Dictionary) -> Dictionary: + var fields = {} + var var_type : String = "" + for field in dict.keys(): + var field_value = dict[field] + if field is String and "." in field: + var keys: Array = field.split(".") + field = keys.pop_front() + keys.reverse() + for key in keys: + field_value = { key : field_value } + + match typeof(field_value): + TYPE_NIL: var_type = "nullValue" + TYPE_BOOL: var_type = "booleanValue" + TYPE_INT: var_type = "integerValue" + TYPE_FLOAT: var_type = "doubleValue" + TYPE_STRING: var_type = "stringValue" + TYPE_DICTIONARY: + if is_field_timestamp(field_value): + var_type = "timestampValue" + field_value = dict2timestamp(field_value) + else: + var_type = "mapValue" + field_value = dict2fields(field_value) + TYPE_ARRAY: + var_type = "arrayValue" + field_value = {"values": array2fields(field_value)} + + if fields.has(field) and fields[field].has("mapValue") and field_value.has("fields"): + for key in field_value["fields"].keys(): + fields[field]["mapValue"]["fields"][key] = field_value["fields"][key] + else: + fields[field] = { var_type : field_value } + + return {'fields' : fields} + +static func from_firebase_type(value : Variant) -> Variant: + if value == null: + return null + + if value.has("mapValue"): + value = _from_firebase_type_recursive(value.values()[0].fields) + elif value.has("timestampValue"): + value = Time.get_datetime_dict_from_datetime_string(value.values()[0], false) + else: + value = value.values()[0] + + return value + +static func _from_firebase_type_recursive(value : Variant) -> Variant: + if value == null: + return null + + if value.has("mapValue") or value.has("timestampValue"): + value = _from_firebase_type_recursive(value.value()[0].fields) + else: + value = value.values()[0] + + return value + +static func to_firebase_type(value : Variant) -> Dictionary: + var var_type : String = "" + + match typeof(value): + TYPE_NIL: var_type = "nullValue" + TYPE_BOOL: var_type = "booleanValue" + TYPE_INT: var_type = "integerValue" + TYPE_FLOAT: var_type = "doubleValue" + TYPE_STRING: var_type = "stringValue" + TYPE_DICTIONARY: + if is_field_timestamp(value): + var_type = "timestampValue" + value = dict2timestamp(value) + else: + var_type = "mapValue" + value = dict2fields(value) + TYPE_ARRAY: + var_type = "arrayValue" + value = {"values": array2fields(value)} + + return { var_type : value } + +# Pass the .fields inside a Firestore Document to print out the Dictionary { 'key' : 'value' } +static func fields2dict(doc) -> Dictionary: + var dict = {} + if doc.has("fields"): + var fields = doc["fields"] + print(fields) + for field in fields.keys(): + if fields[field].has("mapValue"): + dict[field] = (fields2dict(fields[field].mapValue)) + elif fields[field].has("timestampValue"): + dict[field] = timestamp2dict(fields[field].timestampValue) + elif fields[field].has("arrayValue"): + dict[field] = fields2array(fields[field].arrayValue) + elif fields[field].has("integerValue"): + dict[field] = fields[field].values()[0] as int + elif fields[field].has("doubleValue"): + dict[field] = fields[field].values()[0] as float + elif fields[field].has("booleanValue"): + dict[field] = fields[field].values()[0] as bool + elif fields[field].has("nullValue"): + dict[field] = null + else: + dict[field] = fields[field].values()[0] + return dict + +# Pass an Array to parse it to a Firebase arrayValue +static func array2fields(array : Array) -> Array: + var fields : Array = [] + var var_type : String = "" + for field in array: + match typeof(field): + TYPE_DICTIONARY: + if is_field_timestamp(field): + var_type = "timestampValue" + field = dict2timestamp(field) + else: + var_type = "mapValue" + field = dict2fields(field) + TYPE_NIL: var_type = "nullValue" + TYPE_BOOL: var_type = "booleanValue" + TYPE_INT: var_type = "integerValue" + TYPE_FLOAT: var_type = "doubleValue" + TYPE_STRING: var_type = "stringValue" + TYPE_ARRAY: var_type = "arrayValue" + _: var_type = "FieldTransform" + fields.append({ var_type : field }) + return fields + +# Pass a Firebase arrayValue Dictionary to convert it back to an Array +static func fields2array(array : Dictionary) -> Array: + var fields : Array = [] + if array.has("values"): + for field in array.values: + var item + match field.keys()[0]: + "mapValue": + item = fields2dict(field.mapValue) + "arrayValue": + item = fields2array(field.arrayValue) + "integerValue": + item = field.values()[0] as int + "doubleValue": + item = field.values()[0] as float + "booleanValue": + item = field.values()[0] as bool + "timestampValue": + item = timestamp2dict(field.timestampValue) + "nullValue": + item = null + _: + item = field.values()[0] + fields.append(item) + return fields + +# Converts a gdscript Dictionary (most likely obtained with Time.get_datetime_dict_from_system()) to a Firebase Timestamp +static func dict2timestamp(dict : Dictionary) -> String: + #dict.erase('weekday') + #dict.erase('dst') + #var dict_values : Array = dict.values() + var time = Time.get_datetime_string_from_datetime_dict(dict, false) + return time + #return "%04d-%02d-%02dT%02d:%02d:%02d.00Z" % dict_values + +# Converts a Firebase Timestamp back to a gdscript Dictionary +static func timestamp2dict(timestamp : String) -> Dictionary: + return Time.get_datetime_dict_from_datetime_string(timestamp, false) + #var datetime : Dictionary = {year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0} + #var dict : PackedStringArray = timestamp.split("T")[0].split("-") + #dict.append_array(timestamp.split("T")[1].split(":")) + #for value in dict.size(): + #datetime[datetime.keys()[value]] = int(dict[value]) + #return datetime + +static func is_field_timestamp(field : Dictionary) -> bool: + return field.has_all(['year','month','day','hour','minute','second']) + + # HTTPRequeust seems to have an issue in Web exports where the body returns empty # This appears to be caused by the gzip compression being unsupported, so we # disable it when web export is detected. @@ -133,3 +316,11 @@ class ObservableDictionary extends RefCounted: func _set(property: StringName, value: Variant) -> bool: update(property, value) return true + +class AwaitDetachable extends Node2D: + var awaiter : Signal + + func _init(freeable_node, await_signal : Signal) -> void: + awaiter = await_signal + add_child(freeable_node) + awaiter.connect(queue_free) diff --git a/addons/godot-firebase/firestore/firestore.gd b/addons/godot-firebase/firestore/firestore.gd index 979006d..772a228 100644 --- a/addons/godot-firebase/firestore/firestore.gd +++ b/addons/godot-firebase/firestore/firestore.gd @@ -18,16 +18,8 @@ extends Node const _API_VERSION : String = "v1" -## Emitted when a [code]list()[/code] request is successfully completed. [code]error()[/code] signal will be emitted otherwise. -## @arg-types Array -signal listed_documents(documents) -## Emitted when a [code]query()[/code] request is successfully completed. [code]error()[/code] signal will be emitted otherwise. -## @arg-types Array -signal result_query(result) -## Emitted when a [code]query()[/code] request is successfully completed. [code]error()[/code] signal will be emitted otherwise. -## @arg-types Array ## Emitted when a [code]list()[/code] or [code]query()[/code] request is [b]not[/b] successfully completed. -signal task_error(code,status,message) +signal error(code, status, message) enum Requests { NONE = -1, ## Firestore is not processing any request. @@ -58,10 +50,6 @@ var persistence_enabled : bool = false ## @default true var networking: bool = true : set = set_networking -## A Dictionary containing all collections currently referenced. -## @type Dictionary -var collections : Dictionary = {} - ## A Dictionary containing all authentication fields for the current logged user. ## @type Dictionary var auth : Dictionary @@ -81,25 +69,11 @@ var _request_list_node : HTTPRequest var _requests_queue : Array = [] var _current_query : FirestoreQuery -var _http_request_pool := [] - var _offline: bool = false : set = _set_offline func _ready() -> void: pass -func _process(delta : float) -> void: - for i in range(_http_request_pool.size() - 1, -1, -1): - var request = _http_request_pool[i] - if not request.get_meta("requesting"): - var lifetime: float = request.get_meta("lifetime") + delta - if lifetime > _MAX_POOLED_REQUEST_AGE: - request.queue_free() - _http_request_pool.remove_at(i) - continue # Just to skip set_meta on a queue_freed request - request.set_meta("lifetime", lifetime) - - ## Returns a reference collection by its [i]path[/i]. ## ## The returned object will be of [code]FirestoreCollection[/code] type. @@ -107,17 +81,19 @@ func _process(delta : float) -> void: ## @args path ## @return FirestoreCollection func collection(path : String) -> FirestoreCollection: - if not collections.has(path): - 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 - collections[path] = coll - return coll - else: - return collections[path] + 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. @@ -139,19 +115,17 @@ func collection(path : String) -> FirestoreCollection: ## @args query ## @arg-types FirestoreQuery ## @return FirestoreTask -func query(query : FirestoreQuery) -> FirestoreTask: - var firestore_task : FirestoreTask = FirestoreTask.new() - firestore_task.result_query.connect(_on_result_query) # In theory, this and the following could be a CONNECT_ONE_SHOT, but I'm iffy on whether or not that might break any client code, so leaving this for now. - firestore_task.task_error.connect(_on_task_error) - firestore_task.action = FirestoreTask.Task.TASK_QUERY +func query(query : FirestoreQuery) -> Array: + 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_suffix - firestore_task.data = query - firestore_task._fields = JSON.stringify(body) - firestore_task._url = url - _pooled_request(firestore_task) - return firestore_task + task.data = query + task._fields = JSON.stringify(body) + task._url = url + _pooled_request(task) + return await _handle_task_finished(task) ## Request a list of contents (documents and/or collections) inside a collection, specified by its [i]id[/i]. This method will return a [code]FirestoreTask[/code] object, representing a reference to the request issued. If saved into a variable, the [code]FirestoreTask[/code] object can be used to yield checked the [code]result_query(result)[/code] signal, or the more generic [code]task_finished(result)[/code] signal. @@ -168,11 +142,9 @@ func query(query : FirestoreQuery) -> FirestoreTask: ## @arg-types String, int, String, String ## @arg-defaults , 0, "", "" ## @return FirestoreTask -func list(path : String = "", page_size : int = 0, page_token : String = "", order_by : String = "") -> FirestoreTask: - var firestore_task : FirestoreTask = FirestoreTask.new() - firestore_task.listed_documents.connect(_on_listed_documents) # Same as above with one shot connections - firestore_task.task_error.connect(_on_task_error) - firestore_task.action = FirestoreTask.Task.TASK_LIST +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) @@ -181,10 +153,11 @@ func list(path : String = "", page_size : int = 0, page_token : String = "", ord if order_by != "": url+="&orderBy="+order_by - firestore_task.data = [path, page_size, page_token, order_by] - firestore_task._url = url - _pooled_request(firestore_task) - return firestore_task + task.data = [path, page_size, page_token, order_by] + task._url = url + _pooled_request(task) + + return await _handle_task_finished(task) func set_networking(value: bool) -> void: @@ -199,8 +172,9 @@ func enable_networking() -> void: return networking = true _base_url = _base_url.replace("storeoffline", "firestore") - for key in collections: - collections[key]._base_url = _base_url + for coll in get_children(): + if coll is FirestoreCollection: + coll._base_url = _base_url func disable_networking() -> void: @@ -209,8 +183,9 @@ func disable_networking() -> void: networking = false # Pointing to an invalid url should do the trick. _base_url = _base_url.replace("firestore", "storeoffline") - for key in collections: - collections[key]._base_url = _base_url + for coll in get_children(): + if coll is FirestoreCollection: + coll._base_url = _base_url func _set_offline(value: bool) -> void: @@ -226,7 +201,6 @@ func _set_config(config_json : Dictionary) -> void: _check_emulating() - func _check_emulating() -> void : ## Check emulating if not Firebase.emulating: @@ -254,55 +228,29 @@ func _pooled_request(task : FirestoreTask) -> void: if not Firebase.emulating: task._headers = PackedStringArray([_AUTHORIZATION_HEADER + auth.idtoken]) - var http_request : HTTPRequest - for request in _http_request_pool: - if not request.get_meta("requesting"): - http_request = request - break - - if not http_request: - http_request = HTTPRequest.new() - http_request.timeout = 5 - Utilities.fix_http_request(http_request) - _http_request_pool.append(http_request) - add_child(http_request) - http_request.request_completed.connect(_on_pooled_request_completed.bind(http_request)) - - http_request.set_meta("requesting", true) - http_request.set_meta("lifetime", 0.0) - http_request.set_meta("task", task) + 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_listed_documents(_listed_documents : Array): - listed_documents.emit(_listed_documents) - -func _on_result_query(result : Array): - result_query.emit(result) - -func _on_task_error(code : int, status : String, message : String, task : int): - task_error.emit(code, status, message) - Firebase._printerr(message) - func _on_FirebaseAuth_login_succeeded(auth_result : Dictionary) -> void: auth = auth_result - for key in collections: - collections[key].auth = auth - + 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 key in collections: - collections[key].auth = auth - - -func _on_pooled_request_completed(result : int, response_code : int, headers : PackedStringArray, body : PackedByteArray, request : HTTPRequest) -> void: - request.get_meta("task")._on_request_completed(result, response_code, headers, body) - request.set_meta("requesting", false) - + for coll in get_children(): + if coll is FirestoreCollection: + coll.auth = auth func _on_connect_check_request_completed(result : int, _response_code, _headers, _body) -> void: _set_offline(result != HTTPRequest.RESULT_SUCCESS) @@ -318,3 +266,11 @@ func _check_auth_error(code : int, message : String) -> void: 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 diff --git a/addons/godot-firebase/firestore/firestore_collection.gd b/addons/godot-firebase/firestore/firestore_collection.gd index 56d8bc0..0dae95f 100644 --- a/addons/godot-firebase/firestore/firestore_collection.gd +++ b/addons/godot-firebase/firestore/firestore_collection.gd @@ -5,14 +5,9 @@ ## Documentation TODO. @tool class_name FirestoreCollection -extends RefCounted +extends Node -signal add_document(doc) -signal get_document(doc) -signal update_document(doc) -signal commit_document(result) -signal delete_document(deleted) -signal error(code,status,message) +signal error(error_result) const _AUTHORIZATION_HEADER : String = "Authorization: Bearer " @@ -28,70 +23,104 @@ var _extended_url : String var _config : Dictionary var _documents := {} -var _request_queues := {} # ----------------------- Requests ## @args document_id ## @return FirestoreTask ## used to GET a document from the collection, specify @document_id -func get_doc(document_id : String) -> FirestoreTask: +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") - task.get_document.connect(_on_get_document) - task.task_finished.connect(_on_task_finished.bind(document_id), CONNECT_DEFERRED) _process_request(task, document_id, url) - return task + 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 FirestoreTask -## used to SAVE/ADD a new document to the collection, specify @documentID and @fields -func add(document_id : String, fields : Dictionary = {}) -> FirestoreTask: +## @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 - task.add_document.connect(_on_add_document) - task.task_finished.connect(_on_task_finished.bind(document_id), CONNECT_DEFERRED) - _process_request(task, document_id, url, JSON.stringify(FirestoreDocument.dict2fields(fields))) - return task - -## @args document_id, fields -## @arg-defaults , {} -## @return FirestoreTask -# used to UPDATE a document, specify @documentID, @fields -func update(document_id : String, fields : Dictionary = {}) -> FirestoreTask: + _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_id - var url = _get_request_url() + _separator + document_id.replace(" ", "%20") + "?" - for key in fields.keys(): + 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 fields.keys(): - if fields[key] == null: - fields.erase(key) + for key in document.keys(): + if document.get_value(key) == null: + document._erase(key) - task.update_document.connect(_on_update_document) - task.task_finished.connect(_on_task_finished.bind(document_id), CONNECT_DEFERRED) - var body = FirestoreDocument.dict2fields(fields) + var temp_transforms + if document._transforms != null: + temp_transforms = document._transforms + document._transforms = null - _process_request(task, document_id, url, JSON.stringify(body)) - return task + 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 -func commit(document : FirestoreDocument) -> FirestoreTask: + 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 = _base_url + _extended_url.rstrip("/") + ":commit" - task.commit_document.connect(_on_commit_document) - task.task_finished.connect(_on_task_finished.bind(document.doc_name), CONNECT_DEFERRED) + var url = get_database_url("commit") document._transforms.set_config( { @@ -101,32 +130,36 @@ func commit(document : FirestoreDocument) -> FirestoreTask: ) # 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 task + + 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 @document_id -func delete(document_id : String) -> 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 = collection_name + "/" + document_id - var url = _get_request_url() + _separator + document_id.replace(" ", "%20") - - task.delete_document.connect(_on_delete_document) - task.task_finished.connect(_on_task_finished.bind(document_id), CONNECT_DEFERRED) - _process_request(task, document_id, url) - return task + 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 -# ----------------- Functions 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 not task.task_error.is_connected(_on_error): - task.task_error.connect(_on_error) - if auth == null or auth.is_empty(): Firebase._print("Unauthenticated request issued...") Firebase.Auth.login_anonymous() @@ -139,32 +172,7 @@ func _process_request(task : FirestoreTask, document_id : String, url : String, task._url = url task._fields = fields task._headers = PackedStringArray([_AUTHORIZATION_HEADER + auth.idtoken]) - if _request_queues.has(document_id) and not _request_queues[document_id].is_empty(): - _request_queues[document_id].append(task) - else: - _request_queues[document_id] = [] - Firebase.Firestore._pooled_request(task) - -func _on_task_finished(task : FirestoreTask, document_id : String) -> void: - if not _request_queues[document_id].is_empty(): - task._push_request(task._url, _AUTHORIZATION_HEADER + auth.idtoken, task._fields) - -# -------------------- Higher level of communication with signals -func _on_get_document(document : FirestoreDocument): - get_document.emit(document) - -func _on_add_document(document : FirestoreDocument): - add_document.emit(document) - -func _on_update_document(document : FirestoreDocument): - update_document.emit(document) - -func _on_delete_document(deleted): - delete_document.emit(deleted) - -func _on_error(code, status, message, task): - error.emit(code, status, message) - Firebase._printerr(message) + Firebase.Firestore._pooled_request(task) -func _on_commit_document(result): - commit_document.emit(result) +func get_database_url(append) -> String: + return _base_url + _extended_url.rstrip("/") + ":" + append diff --git a/addons/godot-firebase/firestore/firestore_document.gd b/addons/godot-firebase/firestore/firestore_document.gd index f31520f..f0f9249 100644 --- a/addons/godot-firebase/firestore/firestore_document.gd +++ b/addons/godot-firebase/firestore/firestore_document.gd @@ -4,7 +4,7 @@ ## Documentation TODO. @tool class_name FirestoreDocument -extends RefCounted +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 @@ -12,169 +12,133 @@ extends RefCounted # created when requested from a `collection().get()` call var document : Dictionary # the Document itself -var doc_fields : Dictionary # only .fields 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 = {},_doc_name : String = "",_doc_fields : Dictionary = {}): +func _init(doc : Dictionary = {}): _transforms = FieldTransformArray.new() - document = doc + document = doc.fields doc_name = doc.name if doc_name.count("/") > 2: doc_name = (doc_name.split("/") as Array).back() - doc_fields = fields2dict(self.document) - self.create_time = doc.createTime -# Pass a dictionary { 'key' : 'value' } to format it in a APIs usable .fields -# Field Path3D using the "dot" (`.`) notation are supported: -# ex. { "PATH.TO.SUBKEY" : "VALUE" } ==> { "PATH" : { "TO" : { "SUBKEY" : "VALUE" } } } -static func dict2fields(dict : Dictionary) -> Dictionary: - var fields = {} - var var_type : String = "" - for field in dict.keys(): - var field_value = dict[field] - if "." in field: - var keys: Array = field.split(".") - field = keys.pop_front() - keys.reverse() - for key in keys: - field_value = { key : field_value } - - match typeof(field_value): - TYPE_NIL: var_type = "nullValue" - TYPE_BOOL: var_type = "booleanValue" - TYPE_INT: var_type = "integerValue" - TYPE_FLOAT: var_type = "doubleValue" - TYPE_STRING: var_type = "stringValue" - TYPE_DICTIONARY: - if is_field_timestamp(field_value): - var_type = "timestampValue" - field_value = dict2timestamp(field_value) - else: - var_type = "mapValue" - field_value = dict2fields(field_value) - TYPE_ARRAY: - var_type = "arrayValue" - field_value = {"values": array2fields(field_value)} - - if fields.has(field) and fields[field].has("mapValue") and field_value.has("fields"): - for key in field_value["fields"].keys(): - fields[field]["mapValue"]["fields"][key] = field_value["fields"][key] +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: - fields[field] = { var_type : field_value } - - return {'fields' : fields} + var new_value = Utilities.from_firebase_type(document[key]) + var old_value = Utilities.from_firebase_type(current[key]) + if 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] = null + document[field_path] = Utilities.to_firebase_type(null) - if doc_fields.has(field_path): - doc_fields[field_path] = null - -# Pass the .fields inside a Firestore Document to print out the Dictionary { 'key' : 'value' } -static func fields2dict(doc) -> Dictionary: - var dict = {} - if doc.has("fields"): - var fields = doc["fields"] - print(fields) - for field in fields.keys(): - if fields[field].has("mapValue"): - dict[field] = (fields2dict(fields[field].mapValue)) - elif fields[field].has("timestampValue"): - dict[field] = timestamp2dict(fields[field].timestampValue) - elif fields[field].has("arrayValue"): - dict[field] = fields2array(fields[field].arrayValue) - elif fields[field].has("integerValue"): - dict[field] = fields[field].values()[0] as int - elif fields[field].has("doubleValue"): - dict[field] = fields[field].values()[0] as float - elif fields[field].has("booleanValue"): - dict[field] = fields[field].values()[0] as bool - elif fields[field].has("nullValue"): - dict[field] = null - else: - dict[field] = fields[field].values()[0] - return dict + 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) -# Pass an Array to parse it to a Firebase arrayValue -static func array2fields(array : Array) -> Array: - var fields : Array = [] - var var_type : String = "" - for field in array: - match typeof(field): - TYPE_DICTIONARY: - if is_field_timestamp(field): - var_type = "timestampValue" - field = dict2timestamp(field) - else: - var_type = "mapValue" - field = dict2fields(field) - TYPE_NIL: var_type = "nullValue" - TYPE_BOOL: var_type = "booleanValue" - TYPE_INT: var_type = "integerValue" - TYPE_FLOAT: var_type = "doubleValue" - TYPE_STRING: var_type = "stringValue" - TYPE_ARRAY: var_type = "arrayValue" - _: var_type = "FieldTransform" - fields.append({ var_type : field }) - return fields +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 }) -# Pass a Firebase arrayValue Dictionary to convert it back to an Array -static func fields2array(array : Dictionary) -> Array: - var fields : Array = [] - if array.has("values"): - for field in array.values: - var item - match field.keys()[0]: - "mapValue": - item = fields2dict(field.mapValue) - "arrayValue": - item = fields2array(field.arrayValue) - "integerValue": - item = field.values()[0] as int - "doubleValue": - item = field.values()[0] as float - "booleanValue": - item = field.values()[0] as bool - "timestampValue": - item = timestamp2dict(field.timestampValue) - "nullValue": - item = null - _: - item = field.values()[0] - fields.append(item) - return fields + 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 -# Converts a gdscript Dictionary (most likely obtained with Time.get_datetime_dict_from_system()) to a Firebase Timestamp -static func dict2timestamp(dict : Dictionary) -> String: - dict.erase('weekday') - dict.erase('dst') - var dict_values : Array = dict.values() - return "%04d-%02d-%02dT%02d:%02d:%02d.00Z" % dict_values +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 -# Converts a Firebase Timestamp back to a gdscript Dictionary -static func timestamp2dict(timestamp : String) -> Dictionary: - var datetime : Dictionary = {year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0} - var dict : PackedStringArray = timestamp.split("T")[0].split("-") - dict.append_array(timestamp.split("T")[1].split(":")) - for value in dict.size() : - datetime[datetime.keys()[value]] = int(dict[value]) - return datetime +func _set(property: StringName, value: Variant) -> bool: + document[property] = Utilities.to_firebase_type(value) + return true -static func is_field_timestamp(field : Dictionary) -> bool: - return field.has_all(['year','month','day','hour','minute','second']) +func keys(): + return document.keys() # Call print(document) to return directly this document formatted func _to_string() -> String: - return ("doc_name: {doc_name}, \ndoc_fields: {doc_fields}, \ncreate_time: {create_time}\n").format( + return ("doc_name: {doc_name}, \ndata: {data}, \ncreate_time: {create_time}\n").format( {doc_name = self.doc_name, - doc_fields = self.doc_fields, + data = document, create_time = self.create_time}) diff --git a/addons/godot-firebase/firestore/firestore_listener.gd b/addons/godot-firebase/firestore/firestore_listener.gd new file mode 100644 index 0000000..7808a5c --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_listener.gd @@ -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() diff --git a/addons/godot-firebase/firestore/firestore_listener.tscn b/addons/godot-firebase/firestore/firestore_listener.tscn new file mode 100644 index 0000000..9f5e246 --- /dev/null +++ b/addons/godot-firebase/firestore/firestore_listener.tscn @@ -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") diff --git a/addons/godot-firebase/firestore/firestore_query.gd b/addons/godot-firebase/firestore/firestore_query.gd index fc6d034..6e4b764 100644 --- a/addons/godot-firebase/firestore/firestore_query.gd +++ b/addons/godot-firebase/firestore/firestore_query.gd @@ -195,7 +195,7 @@ func limit(limit : int) -> FirestoreQuery: # UTILITIES ---------------------------------------- static func _cursor_object(value, before : bool) -> Cursor: - var parse : Dictionary = FirestoreDocument.dict2fields({value = value}).fields.value + 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 @@ -210,7 +210,7 @@ func create_field_filter(field : String, operator : int, value) -> Dictionary: fieldFilter = { field = { fieldPath = field }, op = OPERATOR.keys()[operator], - value = FirestoreDocument.dict2fields({value = value}).fields.value + value = Utilities.dict2fields({value = value}).fields.value } } func create_unary_filter(field : String, operator : int) -> Dictionary: diff --git a/addons/godot-firebase/firestore/firestore_task.gd b/addons/godot-firebase/firestore/firestore_task.gd index 8fd659f..9fad023 100644 --- a/addons/godot-firebase/firestore/firestore_task.gd +++ b/addons/godot-firebase/firestore/firestore_task.gd @@ -23,31 +23,7 @@ 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(task) -## Emitted when a [code]add(document)[/code] request checked a [class FirebaseCollection] is successfully completed. [code]error()[/code] signal will be emitted otherwise and [code]null[/code] will be passed as a result.. -## @arg-types FirestoreDocument -signal add_document(doc) -## Emitted when a [code]get(document)[/code] request checked a [class FirebaseCollection] is successfully completed. [code]error()[/code] signal will be emitted otherwise and [code]null[/code] will be passed as a result. -## @arg-types FirestoreDocument -signal get_document(doc) -## Emitted when a [code]update(document)[/code] request checked a [class FirebaseCollection] is successfully completed. [code]error()[/code] signal will be emitted otherwise and [code]null[/code] will be passed as a result. -## @arg-types FirestoreDocument -signal update_document(doc) -## Emitted when a [code]write(document)[/code] request for a document is successfully completed. [code]error()[/code] signal will be emitted otherwise and [code]result[/code] will be passed as a result. -## @arg-types FirestoreDocument -signal commit_document(result) -## Emitted when a [code]delete(document)[/code] request checked a [class FirebaseCollection] is successfully completed and [code]true[/code] will be passed. [code]error()[/code] signal will be emitted otherwise and [code]false[/code] will be passed as a result. -## @arg-types bool -signal delete_document(success) -## Emitted when a [code]list(collection_id)[/code] request checked [class FirebaseFirestore] is successfully completed. [code]error()[/code] signal will be emitted otherwise and [code][][/code] will be passed as a result.. -## @arg-types Array -signal listed_documents(documents) -## Emitted when a [code]query(collection_id)[/code] request checked [class FirebaseFirestore] is successfully completed. [code]error()[/code] signal will be emitted otherwise and [code][][/code] will be passed as a result. -## @arg-types Array -signal result_query(result) -## Emitted when a request is [b]not[/b] successfully completed. -## @arg-types Dictionary -signal task_error(code, status, message, task) +signal task_finished() enum Task { TASK_GET, ## A GET Request Task, processing a get() request @@ -79,8 +55,6 @@ var action : int = -1 : set = set_action var data var error : Dictionary var document : FirestoreDocument -## Whether the data came from cache. -var from_cache : bool = false var _response_headers : PackedStringArray = PackedStringArray() var _response_code : int = 0 @@ -95,31 +69,21 @@ func _on_request_completed(result : int, response_code : int, headers : PackedSt if bod != "": bod = Utilities.get_json_data(bod) - var offline: bool = typeof(bod) == TYPE_NIL var failed: bool = bod is Dictionary and bod.has("error") and response_code != HTTPClient.RESPONSE_OK - from_cache = offline # Probably going to regret this... if response_code == HTTPClient.RESPONSE_OK: - data = bod match action: - Task.TASK_POST: + Task.TASK_POST, Task.TASK_GET, Task.TASK_PATCH: document = FirestoreDocument.new(bod) - add_document.emit(document) - Task.TASK_GET: - document = FirestoreDocument.new(bod) - get_document.emit(document) - Task.TASK_PATCH: - document = FirestoreDocument.new(bod) - update_document.emit(document) + data = document Task.TASK_DELETE: - delete_document.emit(true) + data = true Task.TASK_QUERY: data = [] for doc in bod: if doc.has('document'): data.append(FirestoreDocument.new(doc.document)) - result_query.emit(data) Task.TASK_LIST: data = [] if bod.has('documents'): @@ -127,48 +91,36 @@ func _on_request_completed(result : int, response_code : int, headers : PackedSt data.append(FirestoreDocument.new(doc)) if bod.has("nextPageToken"): data.append(bod.nextPageToken) - listed_documents.emit(data) Task.TASK_COMMIT: - commit_document.emit(bod) + 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) - emit_error(task_error, bod, action) - match action: - Task.TASK_POST: - add_document.emit(null) - Task.TASK_GET: - get_document.emit(null) - Task.TASK_PATCH: - update_document.emit(null) - Task.TASK_DELETE: - delete_document.emit(false) - Task.TASK_QUERY: - data = [] - result_query.emit(data) - Task.TASK_LIST: - data = [] - listed_documents.emit(data) - Task.TASK_COMMIT: - commit_document.emit(null) - - task_finished.emit(self) - -func emit_error(_signal, bod, task) -> void: - if bod: - if bod is Array and bod.size() > 0 and bod[0].has("error"): - error = bod[0].error - elif bod is Dictionary and bod.keys().size() > 0 and bod.has("error"): - error = bod.error - - _signal.emit(error.code, error.status, error.message, task) - - return - - _signal.emit(1, 0, "Unknown error", task) + 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 @@ -185,9 +137,6 @@ func set_action(value : int) -> void: _method = HTTPClient.METHOD_POST -func _handle_cache(offline : bool, data, encrypt_key : String, cache_path : String, body) -> Dictionary: - return body # Removing caching for now, hopefully this works without killing everyone and everything - func _merge_dict(dic_a : Dictionary, dic_b : Dictionary, nullify := false) -> Dictionary: var ret := dic_a.duplicate(true) for key in dic_b: diff --git a/addons/godot-firebase/functions/functions.gd b/addons/godot-firebase/functions/functions.gd index 605a180..8ccfa97 100644 --- a/addons/godot-firebase/functions/functions.gd +++ b/addons/godot-firebase/functions/functions.gd @@ -51,7 +51,7 @@ var _http_request_pool : Array = [] var _offline: bool = false : set = _set_offline func _ready() -> void: - pass + set_process(false) func _process(delta : float) -> void: for i in range(_http_request_pool.size() - 1, -1, -1): @@ -68,6 +68,7 @@ func _process(delta : float) -> void: ## @args ## @return FunctionTask func execute(function: String, method: int, params: Dictionary = {}, body: Dictionary = {}) -> FunctionTask: + set_process(true) var function_task : FunctionTask = FunctionTask.new() function_task.task_error.connect(_on_task_error) function_task.task_finished.connect(_on_task_finished)