extends Resource class_name SQLiteObject ## A Data Object representative of data to store in [SQLite] database. ## ## [SQLiteObject] is the core class for GDataORM. It handles the grunt work ## of defining what table structure is and any special flags that are needed ## for SQLite.[br][br] ## ## [b]Example:[/b] ## [codeblock] ## extends SQLiteObject ## class_name Account ## ## var id: int ## var username: String ## var password: String ## var address: Address ## ## static func _setup() -> void: ## set_table_name(Account, "accounts") ## set_column_flags(Account, "id", Flags.PRIMARY_KEY | Flags.AUTO_INCREMENT | Flags.NOT_NULL) ## set_column_flags(Account, "username", Flags.NOT_NULL) ## set_column_flags(Account, "password", Flags.NOT_NULL) ## [/codeblock] ## The supported types of [SQLiteObject] enum DataType { ## A [bool] Value BOOL, ## An [int] Value INT, ## A [float] Value REAL, ## A Variable Length [String] Value STRING, ## A [Dictionary] Value DICTIONARY, ## An [Array] Value ARRAY, ## A value of a built-in Godot DataType, or Object of a Custom Class. GODOT_DATATYPE, ## A Fixed-size [String] value, like [PackedStringArray] CHAR, ## A Binary value, like [PackedByteArray] BLOB } const _BaseTypes = { TYPE_BOOL: DataType.BOOL, TYPE_INT: DataType.INT, TYPE_FLOAT: DataType.REAL, TYPE_STRING: DataType.STRING, TYPE_DICTIONARY: DataType.DICTIONARY, TYPE_ARRAY: DataType.ARRAY, } const _DEFINITION = [ "int", "int", "real", "text", "text", "text", "blob", "char(%d)", "blob" ] ## SQLite flags used for column definitions. enum Flags { ## No Flags Associated with this Column NONE = 1 << 0, ## Column must not be Null. NOT_NULL = 1 << 1, ## Column must be Unique UNIQUE = 1 << 2, ## Column has a Default value. DEFAULT = 1 << 3, ## Column is defined as a Primary Key for this table. PRIMARY_KEY = 1 << 4, ## Column is defined as auto-incrementing. AUTO_INCREMENT = 1 << 5, ## Column is a Foreign Key (See [SQLite] about Foreign Keys) FOREIGN_KEY = 1 << 6, } class TableDefs: var columns: Dictionary[String, Dictionary] = {} var types: Dictionary[String, DataType] = {} var klass: GDScript var table_name: String static var _tables: Dictionary[GDScript, TableDefs] = {} static var _registry: Dictionary[String, GDScript] = {} var _db: SQLite ## A debugging utility to see what classes have been registered with [SQLiteObject]. ## This is printed out to the terminal/output window for easy review. static func print_registered_classes() -> void: print("SQLiteObject Registered Classes:") for klass_name in _registry: print(klass_name) ## A debugging utility to see the structure of all the classes registered with [SQLiteObject]. ## This is printed out to the terminal/output window for easy review. static func print_data_structure() -> void: print("SQLite Object Data Structure:") print("-----------------------------") for klass in _tables: var table = _tables[klass] print("SQLiteObject>%s" % klass.get_global_name()) print("Table Name: %s" % table.table_name) print("COLUMNS:") for column in table.columns: var keys: Array = table.columns[column].keys().filter(func(x): return x != "data_type") var columns := [table.columns[column].data_type] columns.append_array(keys) print("\t%s(DataType.%s) - SQLite: (%s)" % [ column, DataType.find_key(table.types[column]), ", ".join(columns) ]) print("") pass ## This function is called once when setting up the class. This is automatically done with classes ## that are registered as a [DbSet] by the [method Context.setup] static function call. static func setup(klass: GDScript) -> void: _registry[klass.get_global_name()] = klass var table: TableDefs if _tables.has(klass): table = _tables[klass] else: table = TableDefs.new() table.klass = klass table.table_name = klass.get_global_name() _tables[klass] = table for prop in klass.get_script_property_list(): if not prop.usage & PROPERTY_USAGE_SCRIPT_VARIABLE: continue if prop.name.begins_with("_"): continue var def = {} if _BaseTypes.has(prop.type): def.data_type = _DEFINITION[_BaseTypes[prop.type]] table.types[prop.name] = _BaseTypes[prop.type] else: def.data_type = _DEFINITION[DataType.GODOT_DATATYPE] table.types[prop.name] = DataType.GODOT_DATATYPE table.columns[prop.name] = def klass._setup() ## This is a virtual function that is called when setup() is called. This allows you to ## setup the data class information such as Column Flags, Table Name and Column Types. static func _setup() -> void: push_warning("No setup has been defined for this class. No special column flags or types will be used.") ## This function allows you to set SQLite specific flags for columns, when storing the data. ## This function should only be called in [method SQLiteObject._setup] which is part of the ## initialization of the data.[br][br] ## [b]Example:[/b] ## [codeblock] ## static func _setup() -> void: ## # Ensure ID is an Auto-Increment Primary key in the database, that is not allowed to be null. ## set_column_flag(MyDataClass, "id", Flags.PRIMARY_KEY | Flags.AUTO_INCREMENT | Flags.NOT_NULL) ## # Ensure that name is not null in the database, and that it doesn't match any other row of data. ## set_column_flag(MyDataClass, "name", Flags.NOT_NULL | Flags.UNIQUE) ## [/codeblock] static func set_column_flags(klass: GDScript, column: String, flags: int, extra_params: Dictionary = {}) -> void: assert(_tables.has(klass), "Setup must be called first, before setting any column flags!") assert(_tables[klass].columns.has(column), "Column has not been defined! Make sure to declare the variable first!") var data_type = _tables[klass].types[column] var col_def = _tables[klass].columns[column] if flags & Flags.DEFAULT and not extra_params.has("default"): assert(false,"Attempting to set a default, without defining it in extra parameters!") if flags & Flags.AUTO_INCREMENT and not [DataType.INT, DataType.REAL].has(data_type): assert(false, "Attempting to set Auto Increment flag on Non-Integer column!") if flags & Flags.FOREIGN_KEY: if not extra_params.has("table"): assert(false, "Attempting to set Foreign Key flag without defining the Table it associates with!") if not extra_params.has("foreign_key"): assert(false, "Attempting to set Foreign Key flag without defining the Foreign Key!") if flags & Flags.NOT_NULL: col_def.not_null = true if flags & Flags.UNIQUE: col_def.unique = true if flags & Flags.DEFAULT: col_def.default = extra_params.default if flags & Flags.AUTO_INCREMENT: col_def.auto_increment = true if flags & Flags.PRIMARY_KEY: col_def.primary_key = true if flags & Flags.FOREIGN_KEY: col_def.foreign_key = extra_params.foreign_key col_def.foreign_table = extra_params.table _tables[klass].columns[column] = col_def ## Sets the table name to use in the [SQLite] database for storing/fetching data ## from the database. static func set_table_name(klass: GDScript, table_name: String) -> void: assert(_tables.has(klass), "Setup must be called first, before setting the table name!") _tables[klass].table_name = table_name if table_name != "" else klass.get_global_name() ## Sets the column type of [enum SQLiteObject.DataType] along with any extra parameters needed.[br][br] ## [b][color=red]NOTE:[/color][/b] Only use this function if you know what you are doing. GDataORM ## attempts to match the SQLite data type, with the Godot data type as best as possible. static func set_column_type(klass: GDScript, column: String, type: DataType, extra_params: Dictionary = {}) -> void: assert(_tables.has(klass), "Setup must be called first, before setting any column types!") assert(_tables[klass].columns.has(column), "Column has not been defined! Make sure to declare the variable first!") if type == DataType.CHAR and not extra_params.has("size"): assert(false, "Attempting to set Column type to CHAR without a size parameter!") _tables[klass].types[column] = _DEFINITION[type] if type != DataType.CHAR else _DEFINITION[type] % extra_params.size ## Sets a variable that has been defined in the class, to be ignored, so as to not persist the data in the ## [SQLite] database. The variable must be defined, in order for it to be ignored.[br][br] ## [b][color=red]NOTE:[/color][/b] By default, GDataORM ignore's any variables that start with [code]_[/code] character. static func ignore_column(klass: GDScript, column: String) -> void: assert(_tables.has(klass), "Setup must be called first, before ignoring any column types!") assert(_tables[klass].columns.has(column), "Column has not been defined! Make sure to declare the variable first!") _tables[klass].types.erase(column) _tables[klass].columns.erase(column) ## Adds a variable that is normally ignored in the class, to not be ignoerd, so that it can persist the data ## in the [SQLite] database. The variable must be defined, in order for this function to succeed. static func add_column(klass: GDScript, column: String) -> void: assert(_tables.has(klass), "Setup must be called first, before adding any column types!") var props = klass.get_property_list() var res = props.filter(func(x): return x.name == column) assert(res.size() > 0, "You cannot add a column, that does not have the variable defined for it!") var prop = res[0] var def = {} if _BaseTypes.has(prop.type): def.data_type = _DEFINITION[_BaseTypes[prop.type]] _tables[klass].types[prop.name] = _BaseTypes[prop.type] else: def.data_type = _DEFINITION[DataType.GODOT_DATATYPE] _tables[klass].types[prop.name] = DataType.GODOT_DATATYPE _tables[klass].columns[prop.name] = def static func _create_table(db: SQLite, klass: GDScript, drop_if_exists = false) -> void: assert(_tables.has(klass), "Setup must be called first, before setting any column types!") assert(not _tables[klass].columns.is_empty(), "No columns has been defined, either no variables are defined in the GDScript source, or setup was not called first!") if _table_exists(db, klass): if drop_if_exists: db.drop_table(_tables[klass].table_name) else: assert(false, "Table already exists!") db.create_table(_tables[klass].table_name, _tables[klass].columns) static func _table_exists(db: SQLite, klass: GDScript) -> bool: assert(_tables.has(klass), "Setup must be called first, before setting any column types!") var table := _tables[klass] db.query_with_bindings("SELECT name FROM sqlite_master WHERE type='table' AND name=?;", [table.table_name]) return not db.query_result.is_empty() static func _has_id(db: SQLite, klass: GDScript, id: Variant) -> bool: var primary_key = _get_primary_key(klass) var table := _tables[klass] if typeof(id) == TYPE_STRING: db.query_with_bindings("SELECT ? FROM ? WHERE ?='?'", [primary_key, table.table_name, primary_key, id]) else: db.query_with_bindings("SELECT ? FROM ? WHERE ?=?;", [primary_key, table.table_name, primary_key, id]) return not db.query_result.is_empty() static func _get_primary_key(klass: GDScript) -> String: assert(_tables.has(klass), "Setup must be called first, before setting any column types!") var table := _tables[klass] var primary_key: String = "" for column in table.columns: if table.columns[column].has("primary_key"): primary_key = column break assert(primary_key != "", "No primary key has been defined!") return primary_key static func _populate_object(table: TableDefs, obj: SQLiteObject, data: Dictionary) -> void: var props = obj.get_property_list() for key in data: if not props.any(func(x): return x.name == key): continue var prop = props.filter(func(x): return x.name == key)[0] if (table.types[key] == DataType.ARRAY or table.types[key] == DataType.DICTIONARY): obj.get(key).assign(JSON.parse_string(data[key])) elif table.types[key] == DataType.GODOT_DATATYPE: if _registry.has(prop.class_name): var klass := _registry[prop.class_name] var cond := Condition.new() var pk: String = _get_primary_key(klass) cond.equal(pk, bytes_to_var(data[key])) var nobj = _find_one(obj._db, klass, cond) obj.set(key, nobj) else: obj.set(key, bytes_to_var(data[key])) else: obj.set(key, data[key]) static func _find_one(db: SQLite, klass: GDScript, conditions: Condition) -> SQLiteObject: assert(_tables.has(klass), "Setup must be called first, before setting any column types!") var table := _tables[klass] var res := db.select_rows(table.table_name, conditions.to_string(), table.columns.keys()) if res.is_empty(): return null else: var obj = klass.new() obj._db = db _populate_object(table, obj, res[0]) return obj static func _find_many(db: SQLite, klass: GDScript, conditions: Condition) -> Array: assert(_tables.has(klass), "Setup must be called first, before setting any column types!") var table := _tables[klass] var objs: Array = [] var res = db.select_rows(table.table_name, conditions.to_string(), table.columns.keys()) for data in res: var obj = klass.new() obj._db = db _populate_object(table, obj, data) objs.append(obj) return objs static func _all(db: SQLite, klass: GDScript) -> Array: assert(_tables.has(klass), "Setup must be called first, before setting any column types!") var table := _tables[klass] var objs: Array = [] var res = db.select_rows(table.table_name, "", table.columns.keys()) for data in res: var obj = klass.new() obj._db = db _populate_object(table, obj, data) objs.append(obj) return objs ## Verify that the [SQLiteObject] exists in the database. func exists() -> bool: assert(_tables.has(self.get_script()), "Setup must be called first, before setting any column types!") assert(_db, "exists(): This instance was not fetched from the database, or has not been added to a DbSet!") var table := _tables[self.get_script()] var primary_key = _get_primary_key(self.get_script()) assert(primary_key != "", "A Primary Key has not been defined for this class.") var res = _db.select_rows(table.table_name, Condition.new().equal(primary_key, self.get(primary_key)).to_string(), [primary_key]) return not res.is_empty() ## Saves the [SQLiteObject] to the database file.[br][br] ## [b][color=red]NOTE:[/color][/b] Of special note, an object needs to be added to a [DbSet] first through ## [method DbSet.append] for this function to work. [method DbSet.append] will save the object when ## it is first added. This function is mostly for recording updates to the [SQLiteObject] data. func save() -> void: assert(_tables.has(self.get_script()), "Setup must be called first, before setting any column types!") assert(_db, "save(): This instance was not fetched from the database, or has not been added to a DbSet!") var table := _tables[self.get_script()] var primary_key = _get_primary_key(self.get_script()) var sql_data = {} var data: Variant for key in table.columns.keys(): data = get(key) if (table.types[key] == DataType.ARRAY or table.types[key] == DataType.DICTIONARY ): sql_data[key] = JSON.stringify(data) elif table.types[key] == DataType.GODOT_DATATYPE: if typeof(data) == TYPE_OBJECT: if _registry.has(data.get_script().get_global_name()): var pk := _get_primary_key(data.get_script()) var pk_val = data.get(pk) sql_data[key] = var_to_bytes(pk_val) else: sql_data[key] = var_to_bytes(data) else: sql_data[key] = var_to_bytes(data) else: sql_data[key] = data if primary_key != "" and exists(): _db.update_rows(table.table_name,Condition.new().equal(primary_key, get(primary_key)).to_string(), sql_data) else: if primary_key != "" and table.columns[primary_key].auto_increment: sql_data.erase(primary_key) _db.insert_row(table.table_name, sql_data) if primary_key != "" and table.columns[primary_key].auto_increment: var cond := Condition.new().equal("name","%s" % table.table_name) var res := _db.select_rows("sqlite_sequence", cond.to_string(), ["seq"]) assert(not res.is_empty(), "Failed to insert record into %s." % [table.table_name]) set(primary_key, res[0].seq) ## Removes the [SQLiteObject] from the database. This will fail, if the object was not fetched ## from the database first. You can also use [method DbSet.erase] to remove an object from the ## database. func delete() -> void: assert(_tables.has(self.get_script()), "Setup must be called first, before setting any column types!") assert(_db, "delete(): This instance was not fetched from the database, or has not been added to a DbSet!") var table := _tables[self.get_script()] var primary_key = _get_primary_key(self.get_script()) assert(primary_key != "", "In order to delete data from the database, it must have a primary key!") if not exists(): push_warning("Attempting to delete a record that doesn't exist!") return _db.delete_rows(table.table_name, Condition.new().equal(primary_key, get(primary_key)).to_string()) func _to_string() -> String: assert(_tables.has(self.get_script()), "Setup must be called first, before setting any column types!") var table := _tables[self.get_script()] var primary_key = _get_primary_key(self.get_script()) var kname = self.get_script().get_global_name() if primary_key != "": return "<%s:%s:%s>" % [kname, table.table_name, get(primary_key)] else: return "<%s:%s:G-%s>" % [kname, table.table_name, get_instance_id()]