fix: support sqlite

Signed-off-by: Akhil Narang <me@akhilnarang.dev>
This commit is contained in:
Akhil Narang 2025-01-31 11:58:48 +05:30
parent f8ccbfd3d7
commit ad32216040
No known key found for this signature in database
GPG key ID: 9DCC61E211BF645F
23 changed files with 359 additions and 164 deletions

View file

@ -69,9 +69,10 @@ if TYPE_CHECKING: # pragma: no cover
from frappe.database.mariadb.database import MariaDBDatabase as PyMariaDBDatabase from frappe.database.mariadb.database import MariaDBDatabase as PyMariaDBDatabase
from frappe.database.mariadb.mysqlclient import MariaDBDatabase from frappe.database.mariadb.mysqlclient import MariaDBDatabase
from frappe.database.postgres.database import PostgresDatabase from frappe.database.postgres.database import PostgresDatabase
from frappe.database.sqlite.database import SQLiteDatabase
from frappe.email.doctype.email_queue.email_queue import EmailQueue from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.builder import MariaDB, Postgres from frappe.query_builder.builder import MariaDB, Postgres, SQLite
from frappe.types.lazytranslatedstring import _LazyTranslate from frappe.types.lazytranslatedstring import _LazyTranslate
from frappe.utils.redis_wrapper import ClientCache, RedisWrapper from frappe.utils.redis_wrapper import ClientCache, RedisWrapper
@ -161,8 +162,8 @@ ResponseDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
FlagsDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit] FlagsDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
FormDict: TypeAlias = _dict[str, str] FormDict: TypeAlias = _dict[str, str]
db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase"]] = local("db") db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase", "SQLiteDatabase"]] = local("db")
qb: LocalProxy[Union["MariaDB", "Postgres"]] = local("qb") qb: LocalProxy[Union["MariaDB", "Postgres", "SQLite"]] = local("qb")
conf: LocalProxy[ConfType] = local("conf") conf: LocalProxy[ConfType] = local("conf")
form_dict: LocalProxy[FormDict] = local("form_dict") form_dict: LocalProxy[FormDict] = local("form_dict")
form = form_dict form = form_dict
@ -182,7 +183,7 @@ lang: LocalProxy[str] = local("lang")
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
# trick because some type checkers fail to follow "RedisWrapper", etc (written as string literal) # trick because some type checkers fail to follow "RedisWrapper", etc (written as string literal)
# trough a generic wrapper; seems to be a bug # trough a generic wrapper; seems to be a bug
db: PyMariaDBDatabase | MariaDBDatabase | PostgresDatabase db: PyMariaDBDatabase | MariaDBDatabase | PostgresDatabase | SQLiteDatabase
qb: MariaDB | Postgres qb: MariaDB | Postgres
conf: ConfType conf: ConfType
form_dict: FormDict form_dict: FormDict

View file

@ -203,13 +203,24 @@ def build_table_count_cache():
): ):
return return
table_name = frappe.qb.Field("table_name").as_("name") if frappe.db.db_type != "sqlite":
table_rows = frappe.qb.Field("table_rows").as_("count") table_name = frappe.qb.Field("table_name").as_("name")
information_schema = frappe.qb.Schema("information_schema") table_rows = frappe.qb.Field("table_rows").as_("count")
information_schema = frappe.qb.Schema("information_schema")
data = (frappe.qb.from_(information_schema.tables).select(table_name, table_rows)).run(as_dict=True) data = (frappe.qb.from_(information_schema.tables).select(table_name, table_rows)).run(as_dict=True)
counts = {d.get("name").replace("tab", "", 1): d.get("count", None) for d in data} counts = {d.get("name").replace("tab", "", 1): d.get("count", None) for d in data}
frappe.cache.set_value("information_schema:counts", counts) frappe.cache.set_value("information_schema:counts", counts)
else:
counts = {}
name = frappe.qb.Field("name")
type = frappe.qb.Field("type")
sqlite_master = frappe.qb.Schema("sqlite_master")
data = frappe.qb.from_(sqlite_master).select(name).where(type == "table").run(as_dict=True)
for table in data:
count = frappe.db.sql(f"SELECT COUNT(*) FROM `{table.name}`")[0][0]
counts[table.name.replace("tab", "", 1)] = count
frappe.cache.set_value("information_schema:counts", counts)
return counts return counts

View file

@ -524,12 +524,27 @@ def postgres(context: CliCtxObj, extra_args):
_enter_console(extra_args=extra_args) _enter_console(extra_args=extra_args)
@click.command("sqlite", context_settings=EXTRA_ARGS_CTX)
@click.argument("extra_args", nargs=-1)
@pass_context
def sqlite(context: CliCtxObj, extra_args):
"""
Enter into sqlite console for a given site.
"""
site = get_site(context)
frappe.init(site)
frappe.conf.db_type = "sqlite"
_enter_console(extra_args=extra_args)
def _enter_console(extra_args=None): def _enter_console(extra_args=None):
from frappe.database import get_command from frappe.database import get_command
from frappe.utils import get_site_path from frappe.utils import get_site_path
if frappe.conf.db_type == "mariadb": if frappe.conf.db_type == "mariadb":
os.environ["MYSQL_HISTFILE"] = os.path.abspath(get_site_path("logs", "mariadb_console.log")) os.environ["MYSQL_HISTFILE"] = os.path.abspath(get_site_path("logs", "mariadb_console.log"))
elif frappe.conf.db_type == "sqlite":
os.environ["SQLITE_HISTORY"] = os.path.abspath(get_site_path("logs", "sqlite_console.log"))
else: else:
os.environ["PSQL_HISTORY"] = os.path.abspath(get_site_path("logs", "postgresql_console.log")) os.environ["PSQL_HISTORY"] = os.path.abspath(get_site_path("logs", "postgresql_console.log"))
@ -1033,6 +1048,7 @@ commands = [
make_app, make_app,
create_patch, create_patch,
mariadb, mariadb,
sqlite,
postgres, postgres,
request, request,
reset_perms, reset_perms,

View file

@ -34,6 +34,27 @@ def execute(filters=None):
WHERE table_schema = 'public' WHERE table_schema = 'public'
ORDER BY 2 DESC; ORDER BY 2 DESC;
""", """,
"sqlite": """
WITH RECURSIVE
page_size AS (
SELECT CAST(page_size AS FLOAT) as size FROM PRAGMA_page_size()
)
SELECT
m.name as 'table',
ROUND(CAST((SELECT SUM(pgsize) FROM dbstat WHERE name = m.name) * page_size.size / (1024.0 * 1024.0 * 1024.0) AS FLOAT), 2) as 'data_size_mb',
ROUND(CAST((SELECT SUM(pgsize) FROM dbstat WHERE name IN (
SELECT name FROM sqlite_master
WHERE type = 'index' AND tbl_name = m.name
)) * page_size.size / (1024.0 * 1024.0 * 1024.0) AS FLOAT), 2) as 'index_size_mb',
ROUND(CAST((SELECT SUM(pgsize) FROM dbstat WHERE name = m.name OR name IN (
SELECT name FROM sqlite_master
WHERE type = 'index' AND tbl_name = m.name
)) * page_size.size / (1024.0 * 1024.0 * 1024.0) AS FLOAT), 2) as 'total_size_mb'
FROM sqlite_master m
CROSS JOIN page_size
WHERE m.type = 'table'
AND m.name NOT LIKE 'sqlite_%'
ORDER BY total_size_mb DESC;""",
}, },
as_dict=1, as_dict=1,
) )

View file

@ -3,6 +3,7 @@
# Database Module # Database Module
# -------------------- # --------------------
from pathlib import Path
from shutil import which from shutil import which
from frappe.database.database import savepoint from frappe.database.database import savepoint
@ -133,7 +134,10 @@ def get_command(
elif frappe.conf.db_type == "sqlite": elif frappe.conf.db_type == "sqlite":
bin, bin_name = which("sqlite3"), "sqlite3" bin, bin_name = which("sqlite3"), "sqlite3"
command = [] db_path = Path(frappe.get_site_path()) / "db" / f"{db_name}.db"
command = [db_path.as_posix()]
if dump:
command.append(".dump")
else: else:
if dump: if dump:

View file

@ -8,7 +8,7 @@ import re
import string import string
import traceback import traceback
import warnings import warnings
from collections.abc import Hashable, Iterable, Sequence from collections.abc import Iterable, Sequence
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from time import time from time import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any

View file

@ -3,7 +3,7 @@ from frappe import _
class DbManager: class DbManager:
def __init__(self, db): def __init__(self, db: frappe.database.database.Database | None = None):
""" """
Pass root_conn here for access to all databases. Pass root_conn here for access to all databases.
""" """

View file

@ -275,9 +275,9 @@ class DbColumn:
self.table.change_type.append(self) self.table.change_type.append(self)
# unique # unique
if (self.unique and not current_def["unique"]) and column_type not in ("text", "longtext"): if (self.unique and not current_def.get("unique")) and column_type not in ("text", "longtext"):
self.table.add_unique.append(self) self.table.add_unique.append(self)
elif (current_def["unique"] and not self.unique) and column_type not in ("text", "longtext"): elif (current_def.get("unique") and not self.unique) and column_type not in ("text", "longtext"):
self.table.drop_unique.append(self) self.table.drop_unique.append(self)
# default # default
@ -289,21 +289,21 @@ class DbColumn:
self.table.set_default.append(self) self.table.set_default.append(self)
# nullability # nullability
if self.not_nullable is not None and (self.not_nullable != current_def["not_nullable"]): if self.not_nullable is not None and (self.not_nullable != current_def.get("not_nullable")):
self.table.change_nullability.append(self) self.table.change_nullability.append(self)
# index should be applied or dropped irrespective of type change # index should be applied or dropped irrespective of type change
if (current_def["index"] and not self.set_index) and column_type not in ("text", "longtext"): if (current_def.get("index") and not self.set_index) and column_type not in ("text", "longtext"):
self.table.drop_index.append(self) self.table.drop_index.append(self)
elif (not current_def["index"] and self.set_index) and column_type not in ("text", "longtext"): elif (not current_def.get("index") and self.set_index) and column_type not in ("text", "longtext"):
self.table.add_index.append(self) self.table.add_index.append(self)
def default_changed(self, current_def): def default_changed(self, current_def):
if "decimal" in current_def["type"]: if "decimal" in current_def["type"]:
return self.default_changed_for_decimal(current_def) return self.default_changed_for_decimal(current_def)
else: else:
cur_default = current_def["default"] cur_default = current_def.get("default")
new_default = self.default new_default = self.default
if cur_default == "NULL" or cur_default is None: if cur_default == "NULL" or cur_default is None:
cur_default = None cur_default = None

View file

@ -1,11 +1,12 @@
import re import re
import sqlite3 import sqlite3
from contextlib import contextmanager import warnings
from pathlib import Path
import frappe import frappe
from frappe.database.database import Database from frappe.database.database import TRANSACTION_DISABLED_MSG, Database, ImplicitCommitError, is_query_type
from frappe.database.sqlite.schema import SQLiteTable from frappe.database.sqlite.schema import SQLiteTable
from frappe.utils import UnicodeWithAttrs, cstr, get_datetime, get_table_name from frappe.utils import get_table_name
_PARAM_COMP = re.compile(r"%\([\w]*\)s") _PARAM_COMP = re.compile(r"%\([\w]*\)s")
@ -89,23 +90,20 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
MAX_ROW_SIZE_LIMIT = None MAX_ROW_SIZE_LIMIT = None
def get_connection(self): def get_connection(self):
conn = self._get_connection() conn = self.create_connection()
conn.isolation_level = None conn.isolation_level = None
return conn return conn
def _get_connection(self):
"""Return SQLite connection object."""
return self.create_connection()
def create_connection(self): def create_connection(self):
return sqlite3.connect(self.get_connection_settings()) db_path = self.get_db_path()
return sqlite3.connect(db_path)
def get_db_path(self):
return Path(frappe.get_site_path()) / "db" / f"{self.cur_db_name}.db"
def set_execution_timeout(self, seconds: int): def set_execution_timeout(self, seconds: int):
self.sql(f"PRAGMA busy_timeout = {int(seconds) * 1000}") self.sql(f"PRAGMA busy_timeout = {int(seconds) * 1000}")
def get_connection_settings(self) -> str:
return self.cur_db_name
def setup_type_map(self): def setup_type_map(self):
self.db_type = "sqlite" self.db_type = "sqlite"
self.type_map = { self.type_map = {
@ -245,7 +243,7 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
return None return None
def has_index(self, table_name, index_name): def has_index(self, table_name, index_name):
return self.sql(f"PRAGMA index_list(`{table_name}`)") return self.sql(f"SELECT * FROM pragma_index_list(`{table_name}`) WHERE name = '{index_name}'")
def get_column_index(self, table_name: str, fieldname: str, unique: bool = False) -> frappe._dict | None: def get_column_index(self, table_name: str, fieldname: str, unique: bool = False) -> frappe._dict | None:
"""Check if column exists for a specific fields in specified order.""" """Check if column exists for a specific fields in specified order."""
@ -254,18 +252,32 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
index_info = self.sql(f"PRAGMA index_info(`{index['name']}`)", as_dict=True) index_info = self.sql(f"PRAGMA index_info(`{index['name']}`)", as_dict=True)
if index_info and index_info[0]["name"] == fieldname: if index_info and index_info[0]["name"] == fieldname:
return index return index
return None
def add_index(self, doctype: str, fields: list, index_name: str | None = None): def add_index(self, doctype: str, fields: list, index_name: str | None = None):
"""Creates an index with given fields if not already created.""" """Creates an index with given fields if not already created."""
# We can't specify the length of the index in SQLite
fields = [re.sub(r"\(.*?\)", "", field) for field in fields]
index_name = index_name or self.get_index_name(fields) index_name = index_name or self.get_index_name(fields)
table_name = get_table_name(doctype) table_name = get_table_name(doctype)
if not self.has_index(table_name, index_name): self.commit()
self.commit() self.sql(f"CREATE INDEX IF NOT EXISTS `{index_name}` ON `{table_name}` ({', '.join(fields)})")
self.sql(f"CREATE INDEX `{index_name}` ON `{table_name}` ({', '.join(fields)})")
def add_unique(self, doctype, fields, constraint_name=None): def add_unique(self, doctype, fields, constraint_name=None):
raise NotImplementedError("SQLite does not support adding unique constraints directly.") """Creates unique constraint on fields."""
if isinstance(fields, str):
fields = [fields]
if not constraint_name:
constraint_name = f"unique_{'_'.join(fields)}"
table_name = get_table_name(doctype)
columns = ", ".join(fields)
sql_create_unique = (
f"CREATE UNIQUE INDEX IF NOT EXISTS `{constraint_name}` ON `{table_name}` ({columns})"
)
self.commit() # commit before creating index
self.sql(sql_create_unique)
def updatedb(self, doctype, meta=None): def updatedb(self, doctype, meta=None):
"""Syncs a `DocType` to the table.""" """Syncs a `DocType` to the table."""
@ -307,7 +319,8 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
query = query % {x: f"'{y}'" for x, y in values.items()} query = query % {x: f"'{y}'" for x, y in values.items()}
except TypeError: except TypeError:
pass pass
return self._cursor.execute(query, values)
return self._cursor.execute(query, values or ())
def sql(self, *args, **kwargs): def sql(self, *args, **kwargs):
if args: if args:
@ -318,6 +331,90 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
elif kwargs.get("query"): elif kwargs.get("query"):
kwargs["query"] = modify_query(kwargs.get("query")) kwargs["query"] = modify_query(kwargs.get("query"))
return super().sql(*args, **kwargs)
def begin(self, *, read_only=False):
read_only = read_only or frappe.flags.read_only
# mode = "READ ONLY" if read_only else ""
# TODO: support read_only
self.sql("BEGIN")
def commit(self):
"""Commit current transaction. Calls SQL `COMMIT`."""
if not self._conn:
self.connect()
if self._disable_transaction_control:
warnings.warn(message=TRANSACTION_DISABLED_MSG, stacklevel=2)
return
self.before_rollback.reset()
self.after_rollback.reset()
self.before_commit.run()
self._conn.commit()
self.transaction_writes = 0
self.begin() # explicitly start a new transaction
self.after_commit.run()
def rollback(self, *, save_point=None):
"""`ROLLBACK` current transaction. Optionally rollback to a known save_point."""
if not self._conn:
self.connect()
if save_point:
self.sql(f"rollback to savepoint {save_point}")
elif not self._disable_transaction_control:
self.before_commit.reset()
self.after_commit.reset()
self.before_rollback.run()
self._conn.rollback()
self.begin()
self.after_rollback.run()
else:
warnings.warn(message=TRANSACTION_DISABLED_MSG, stacklevel=2)
def get_db_table_columns(self, table) -> list[str]:
"""Return list of column names from given table."""
key = f"table_columns::{table}"
columns = frappe.client_cache.get_value(key)
if columns is None:
columns = self.sql(f"PRAGMA table_info(`{table}`)", as_dict=True)
columns = [col["name"] for col in columns]
if columns:
frappe.cache.set_value(key, columns)
return columns
def check_implicit_commit(self, query: str):
if (
self.transaction_writes
and query
and is_query_type(
query,
("start", "alter", "drop", "create", "truncate", "vacuum", "attach", "detach"),
)
):
raise ImplicitCommitError("This statement can cause implicit commit", query)
def estimate_count(self, doctype: str):
"""Get estimated count of total rows in a table."""
from frappe.utils.data import cint
table = get_table_name(doctype)
try:
if count := self.sql(f"SELECT COUNT(*) FROM `{table}`"):
return cint(count[0][0])
except sqlite3.OperationalError as e:
if not self.is_table_missing(e):
raise
return 0
def modify_query(query): def modify_query(query):
""" """

View file

@ -1,5 +1,3 @@
from pymysql.constants.ER import DUP_ENTRY
import frappe import frappe
from frappe import _ from frappe import _
from frappe.database.schema import DBTable from frappe.database.schema import DBTable
@ -8,149 +6,136 @@ from frappe.utils.defaults import get_not_null_defaults
class SQLiteTable(DBTable): class SQLiteTable(DBTable):
def create(self): def create(self):
# First prepare the basic table creation without indexes
additional_definitions = [] additional_definitions = []
engine = self.meta.get("engine") or "InnoDB" name_column = "name TEXT PRIMARY KEY"
varchar_len = frappe.db.VARCHAR_LEN
name_column = f"name varchar({varchar_len}) primary key"
# columns # columns
column_defs = self.get_column_definitions() column_defs = self.get_column_definitions()
if column_defs: if column_defs:
additional_definitions += column_defs additional_definitions += column_defs
# index index_defs = [] # Store index definitions separately
index_defs = self.get_index_definitions()
if index_defs:
additional_definitions += index_defs
# child table columns # child table columns
if self.meta.get("istable", default=0): if self.meta.get("istable", default=0):
additional_definitions += [ additional_definitions.extend(["parent TEXT", "parentfield TEXT", "parenttype TEXT"])
f"parent varchar({varchar_len})", index_defs.append(f"CREATE INDEX `{self.table_name}_parent_idx` ON `{self.table_name}`(parent)")
f"parentfield varchar({varchar_len})",
f"parenttype varchar({varchar_len})",
"index parent(parent)",
]
else: else:
# parent types # parent types
additional_definitions.append("index creation(creation)") index_defs.append(
f"CREATE INDEX `{self.table_name}_creation_idx` ON `{self.table_name}`(creation)"
)
if self.meta.sort_field == "modified": if self.meta.sort_field == "modified":
# Support old doctype default by indexing it, also 2nd popular choice. index_defs.append(
additional_definitions.append("index modified(modified)") f"CREATE INDEX `{self.table_name}_modified_idx` ON `{self.table_name}`(modified)"
)
# creating sequence(s) # creating sequence(s)
if not self.meta.issingle and self.meta.autoname == "autoincrement": if not self.meta.issingle and self.meta.autoname == "autoincrement":
frappe.db.create_sequence(self.doctype, check_not_exists=True) name_column = "name INTEGER PRIMARY KEY AUTOINCREMENT"
# NOTE: not used nextval func as default as the ability to restore
# database with sequences has bugs in mariadb and gives a scary error.
# issue link: https://jira.mariadb.org/browse/MDEV-20070
name_column = "name bigint primary key"
elif not self.meta.issingle and self.meta.autoname == "UUID": elif not self.meta.issingle and self.meta.autoname == "UUID":
name_column = "name uuid primary key" name_column = "name TEXT PRIMARY KEY"
additional_definitions = ",\n".join(additional_definitions) additional_definitions = ",\n".join(additional_definitions)
# create table # create table
query = f"""create table `{self.table_name}` ( create_table_query = f"""CREATE TABLE `{self.table_name}` (
{name_column}, {name_column},
creation datetime(6), creation DATETIME,
modified datetime(6), modified DATETIME,
modified_by varchar({varchar_len}), modified_by TEXT,
owner varchar({varchar_len}), owner TEXT,
docstatus tinyint not null default '0', docstatus INTEGER NOT NULL DEFAULT 0,
idx int not null default '0', idx INTEGER NOT NULL DEFAULT 0,
{additional_definitions}) {additional_definitions})"""
ENGINE={engine}
ROW_FORMAT=DYNAMIC
CHARACTER SET=utf8mb4
COLLATE=utf8mb4_unicode_ci"""
frappe.db.sql_ddl(query) # Execute table creation
frappe.db.sql_ddl(create_table_query)
# Create indexes separately
for index_query in index_defs:
frappe.db.sql_ddl(index_query)
def alter(self): def alter(self):
for col in self.columns.values(): for col in self.columns.values():
col.build_for_alter_table(self.current_columns.get(col.fieldname.lower())) col.build_for_alter_table(self.current_columns.get(col.fieldname.lower()))
add_column_query = [f"ADD COLUMN `{col.fieldname}` {col.get_definition()}" for col in self.add_column] for col in self.add_column:
frappe.db.sql_ddl(
f"ALTER TABLE `{self.table_name}` ADD COLUMN `{col.fieldname}` {col.get_definition()}"
)
if not (
self.change_type
or self.set_default
or self.change_nullability
or self.add_index
or self.add_unique
or self.drop_index
or self.drop_unique
):
return
# Get current table column definitions
existing_columns = []
for column in frappe.db.sql(f"PRAGMA table_info(`{self.table_name}`)", as_dict=1):
existing_columns.append(f"`{column.name}` {column.type}")
columns = existing_columns.copy()
# Modify existing columns
columns_to_modify = set(self.change_type + self.set_default + self.change_nullability) columns_to_modify = set(self.change_type + self.set_default + self.change_nullability)
modify_column_query = [ for col in columns_to_modify:
f"MODIFY `{col.fieldname}` {col.get_definition(for_modification=True)}" # Replace the old column definition with the new one
for col in columns_to_modify for i, column in enumerate(columns):
] if column.startswith(f"`{col.fieldname}`"):
if alter_pk := self.alter_primary_key(): columns[i] = f"`{col.fieldname}` {col.get_definition(for_modification=True)}"
modify_column_query.append(alter_pk) break
modify_column_query.extend( # Create new table
[f"ADD UNIQUE INDEX IF NOT EXISTS {col.fieldname} (`{col.fieldname}`)" for col in self.add_unique] temp_table = f"{self.table_name}_new"
) create_table = f"CREATE TABLE `{temp_table}` (\n{','.join(columns)}\n)"
add_index_query = [ frappe.db.sql_ddl(create_table)
f"ADD INDEX `{col.fieldname}_index`(`{col.fieldname}`)"
for col in self.add_index
if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False)
]
# Copy data
existing_columns = [col.split()[0] for col in existing_columns]
column_list = ", ".join(existing_columns)
frappe.db.sql_ddl(f"INSERT INTO `{temp_table}` SELECT {column_list} FROM `{self.table_name}`")
# Drop old table
frappe.db.sql_ddl(f"DROP TABLE `{self.table_name}`")
# Rename new table
frappe.db.sql_ddl(f"ALTER TABLE `{temp_table}` RENAME TO `{self.table_name}`")
# Recreate indexes
index_queries = []
if self.add_unique:
index_queries.extend(
f"CREATE UNIQUE INDEX `{col.fieldname}` ON `{self.table_name}` (`{col.fieldname}`)"
for col in self.add_unique
)
if self.add_index:
index_queries.extend(
f"CREATE INDEX `{col.fieldname}_index` ON `{self.table_name}` (`{col.fieldname}`)"
for col in self.add_index
if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False)
)
if self.meta.sort_field == "modified" and not frappe.db.get_column_index( if self.meta.sort_field == "modified" and not frappe.db.get_column_index(
self.table_name, "modified", unique=False self.table_name, "modified", unique=False
): ):
add_index_query.append("ADD INDEX `modified`(`modified`)") index_queries.append(f"CREATE INDEX `modified` ON `{self.table_name}` (`modified`)")
drop_index_query = [] for query in index_queries:
frappe.db.sql_ddl(query)
for col in {*self.drop_index, *self.drop_unique}:
if col.fieldname == "name":
continue
current_column = self.current_columns.get(col.fieldname.lower())
unique_constraint_changed = current_column.unique != col.unique
if unique_constraint_changed and not col.unique:
if unique_index := frappe.db.get_column_index(self.table_name, col.fieldname, unique=True):
drop_index_query.append(f"DROP INDEX `{unique_index.Key_name}`")
index_constraint_changed = current_column.index != col.set_index
if index_constraint_changed and not col.set_index:
if index_record := frappe.db.get_column_index(self.table_name, col.fieldname, unique=False):
drop_index_query.append(f"DROP INDEX `{index_record.Key_name}`")
for col in self.change_nullability:
if col.not_nullable:
try:
table = frappe.qb.DocType(self.doctype)
frappe.qb.update(table).set(
col.fieldname, col.default or get_not_null_defaults(col.fieldtype)
).where(table[col.fieldname].isnull()).run()
except Exception:
print(f"Failed to update data in {self.table_name} for {col.fieldname}")
raise
try:
for query_parts in [add_column_query, modify_column_query, add_index_query, drop_index_query]:
if query_parts:
query_body = ", ".join(query_parts)
query = f"ALTER TABLE `{self.table_name}` {query_body}"
# nosemgrep
frappe.db.sql_ddl(query)
except Exception as e:
if query := locals().get("query"): # this weirdness is to avoid potentially unbounded vars
print(f"Failed to alter schema using query: {query}")
if e.args[0] == DUP_ENTRY:
fieldname = str(e).split("'")[-2]
frappe.throw(
_(
"{0} field cannot be set as unique in {1}, as there are non-unique existing values"
).format(fieldname, self.table_name)
)
raise
def alter_primary_key(self) -> str | None: def alter_primary_key(self) -> str | None:
# If there are no values in table allow migrating to UUID from varchar # If there are no values in table allow migrating to UUID from TEXT
autoname = self.meta.autoname autoname = self.meta.autoname
if autoname == "UUID" and frappe.db.get_column_type(self.doctype, "name") != "uuid": if autoname == "UUID" and frappe.db.get_column_type(self.doctype, "name") != "TEXT":
if not frappe.db.get_value(self.doctype, {}, order_by=None): if not frappe.db.get_value(self.doctype, {}, order_by=None):
return "modify name uuid" return "ALTER COLUMN name TEXT"
else: else:
frappe.throw( frappe.throw(
_("Primary key of doctype {0} can not be changed as there are existing values.").format( _("Primary key of doctype {0} can not be changed as there are existing values.").format(
@ -158,6 +143,6 @@ class SQLiteTable(DBTable):
) )
) )
# Reverting from UUID to VARCHAR # Reverting from UUID to TEXT
if autoname != "UUID" and frappe.db.get_column_type(self.doctype, "name") == "uuid": if autoname != "UUID" and frappe.db.get_column_type(self.doctype, "name") == "TEXT":
return f"modify name varchar({frappe.db.VARCHAR_LEN})" return "ALTER COLUMN name TEXT"

View file

@ -21,8 +21,6 @@ def setup_database(force, verbose):
def bootstrap_database(verbose, source_sql=None): def bootstrap_database(verbose, source_sql=None):
import sys import sys
frappe.connect()
import_db_from_sql(source_sql, verbose) import_db_from_sql(source_sql, verbose)
frappe.connect() frappe.connect()
@ -32,7 +30,7 @@ def bootstrap_database(verbose, source_sql=None):
secho( secho(
"Table 'tabDefaultValue' missing in the restored site. " "Table 'tabDefaultValue' missing in the restored site. "
"This happens when the backup fails to restore. Please check that the file is valid\n" "This happens when the backup fails to restore. Please check that the file is valid\n"
"Do go through the above output to check the exact error message from MariaDB", "Do go through the above output to check the exact error message",
fg="red", fg="red",
) )
sys.exit(1) sys.exit(1)
@ -44,9 +42,7 @@ def import_db_from_sql(source_sql=None, verbose=False):
db_name = frappe.conf.db_name db_name = frappe.conf.db_name
if not source_sql: if not source_sql:
source_sql = os.path.join(os.path.dirname(__file__), "framework_sqlite.sql") source_sql = os.path.join(os.path.dirname(__file__), "framework_sqlite.sql")
DbManager(frappe.local.db).restore_database( DbManager().restore_database(verbose, db_name, source_sql, frappe.conf.db_user, frappe.conf.db_password)
verbose, db_name, source_sql, frappe.conf.db_user, frappe.conf.db_password
)
if verbose: if verbose:
print("Imported from database {}".format(source_sql)) print("Imported from database {}".format(source_sql))

View file

@ -61,6 +61,9 @@ def show_processlist():
def _show_processlist(): def _show_processlist():
if frappe.db.db_type == "sqlite":
return []
return frappe.db.multisql( return frappe.db.multisql(
{ {
"postgres": """ "postgres": """

View file

@ -231,10 +231,25 @@ class SystemHealthReport(Document):
LIMIT 5 LIMIT 5
""" """
sqlite_query = """
SELECT scheduled_job_type,
AVG(CASE WHEN status != 'Complete' THEN 1 ELSE 0 END) * 100 AS failure_rate
FROM `tabScheduled Job Log`
WHERE
creation > %(lower_threshold)s
AND modified > %(lower_threshold)s
AND creation < %(upper_threshold)s
GROUP BY scheduled_job_type
HAVING failure_rate > 0
ORDER BY failure_rate DESC
LIMIT 5
"""
failing_jobs = frappe.db.multisql( failing_jobs = frappe.db.multisql(
{ {
"mariadb": mariadb_query, "mariadb": mariadb_query,
"postgres": postgres_query, "postgres": postgres_query,
"sqlite": sqlite_query,
}, },
{"lower_threshold": lower_threshold, "upper_threshold": upper_threshold}, {"lower_threshold": lower_threshold, "upper_threshold": upper_threshold},
as_dict=True, as_dict=True,

View file

@ -319,15 +319,32 @@ def get_communication_data(
{conditions} {conditions}
""" """
return frappe.db.sql( sqlite_query = f"""
""" SELECT * FROM (
SELECT * FROM ({part1})
UNION ALL
SELECT * FROM ({part2})
) AS combined
{group_by or ""}
ORDER BY communication_date DESC
LIMIT %(limit)s
OFFSET %(start)s"""
query = f"""
SELECT * SELECT *
FROM (({part1}) UNION ({part2})) AS combined FROM (({part1}) UNION ({part2})) AS combined
{group_by} {group_by or ""}
ORDER BY communication_date DESC ORDER BY communication_date DESC
LIMIT %(limit)s LIMIT %(limit)s
OFFSET %(start)s OFFSET %(start)s
""".format(part1=part1, part2=part2, group_by=(group_by or "")), """
return frappe.db.multisql(
{
"sqlite": sqlite_query,
"postgres": query,
"mariadb": query,
},
dict( dict(
doctype=doctype, doctype=doctype,
name=name, name=name,

View file

@ -83,7 +83,7 @@ def get_countries_and_currencies():
symbol=country.currency_symbol, symbol=country.currency_symbol,
fraction_units=country.currency_fraction_units, fraction_units=country.currency_fraction_units,
smallest_currency_fraction_value=country.smallest_currency_fraction_value, smallest_currency_fraction_value=country.smallest_currency_fraction_value,
number_format=country.number_format, number_format=frappe.db.escape(country.number_format)[1:-1],
) )
) )

View file

@ -1180,8 +1180,7 @@ def cast_name(column: str) -> str:
Example: Example:
input - "ifnull(`tabBlog Post`.`name`, '')=''" input - "ifnull(`tabBlog Post`.`name`, '')=''"
output - "ifnull(cast(`tabBlog Post`.`name` as varchar), '')=''" """ output - "ifnull(cast(`tabBlog Post`.`name` as varchar), '')=''" """
if frappe.db.db_type != "postgres":
if frappe.db.db_type == "mariadb":
return column return column
kwargs = {"string": column} kwargs = {"string": column}

View file

@ -232,11 +232,14 @@ class Document(BaseDocument, DocRef):
else: else:
if not is_doctype and isinstance(self.name, str): if not is_doctype and isinstance(self.name, str):
for_update = ""
if self.flags.for_update and frappe.db.db_type != "sqlite":
for_update = "FOR UPDATE"
# Fast path - use raw SQL to avoid QB/ORM overheads. # Fast path - use raw SQL to avoid QB/ORM overheads.
d = frappe.db.sql( d = frappe.db.sql(
"SELECT * FROM {table_name} WHERE `name` = %s {for_update}".format( "SELECT * FROM {table_name} WHERE `name` = %s {for_update}".format(
table_name=get_table_name(self.doctype, wrap_in_backticks=True), table_name=get_table_name(self.doctype, wrap_in_backticks=True),
for_update="FOR UPDATE" if self.flags.for_update else "", for_update=for_update,
), ),
(self.name), (self.name),
as_dict=True, as_dict=True,
@ -289,6 +292,9 @@ class Document(BaseDocument, DocRef):
for_update=self.flags.for_update, for_update=self.flags.for_update,
) )
else: else:
for_update = ""
if self.flags.for_update and frappe.db.db_type != "sqlite":
for_update = "FOR UPDATE"
# Fast pass for all other doctypes - using raw SQL # Fast pass for all other doctypes - using raw SQL
children = frappe.db.sql( children = frappe.db.sql(
"""SELECT * FROM {table_name} """SELECT * FROM {table_name}
@ -297,7 +303,7 @@ class Document(BaseDocument, DocRef):
AND `parentfield`= %(parentfield)s AND `parentfield`= %(parentfield)s
ORDER BY `idx` ASC {for_update}""".format( ORDER BY `idx` ASC {for_update}""".format(
table_name=get_table_name(child_doctype, wrap_in_backticks=True), table_name=get_table_name(child_doctype, wrap_in_backticks=True),
for_update="FOR UPDATE" if self.flags.for_update else "", for_update=for_update,
), ),
{"parent": self.name, "parenttype": self.doctype, "parentfield": fieldname}, {"parent": self.name, "parenttype": self.doctype, "parentfield": fieldname},
as_dict=True, as_dict=True,

View file

@ -57,6 +57,8 @@ def sync_user_settings():
"postgres": """INSERT INTO `__UserSettings` (`user`, `doctype`, `data`) "postgres": """INSERT INTO `__UserSettings` (`user`, `doctype`, `data`)
VALUES (%s, %s, %s) VALUES (%s, %s, %s)
ON CONFLICT ("user", "doctype") DO UPDATE SET `data`=%s""", ON CONFLICT ("user", "doctype") DO UPDATE SET `data`=%s""",
"sqlite": """INSERT OR REPLACE INTO `__UserSettings` (`user`, `doctype`, `data`)
VALUES (%s, %s, %s)""",
}, },
(user, doctype, data, data), (user, doctype, data, data),
as_dict=1, as_dict=1,

View file

@ -6,7 +6,7 @@ from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder, SQLLiteQu
from pypika.queries import QueryBuilder, Schema, Table from pypika.queries import QueryBuilder, Schema, Table
from pypika.terms import Function from pypika.terms import Function
from frappe.query_builder.terms import ParameterizedValueWrapper from frappe.query_builder.terms import ParameterizedValueWrapper, SQLiteParameterizedValueWrapper
from frappe.utils import get_table_name from frappe.utils import get_table_name
@ -100,11 +100,13 @@ class Postgres(Base, PostgreSQLQuery):
class SQLite(Base, SQLLiteQuery): class SQLite(Base, SQLLiteQuery):
Field = terms.Field
_BuilderClasss = SQLLiteQueryBuilder _BuilderClasss = SQLLiteQueryBuilder
@classmethod @classmethod
def _builder(cls, *args, **kwargs) -> "SQLLiteQueryBuilder": def _builder(cls, *args, **kwargs) -> "SQLLiteQueryBuilder":
return super()._builder(*args, wrapper_cls=ParameterizedValueWrapper, **kwargs) return super()._builder(*args, wrapper_cls=SQLiteParameterizedValueWrapper, **kwargs)
@classmethod @classmethod
def from_(cls, table, *args, **kwargs): def from_(cls, table, *args, **kwargs):

View file

@ -1,6 +1,7 @@
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from typing import Any from typing import Any
from pypika.dialects import SQLLiteValueWrapper
from pypika.queries import QueryBuilder from pypika.queries import QueryBuilder
from pypika.terms import Criterion, Function, ValueWrapper from pypika.terms import Criterion, Function, ValueWrapper
from pypika.utils import format_alias_sql from pypika.utils import format_alias_sql
@ -71,6 +72,10 @@ class ParameterizedValueWrapper(ValueWrapper):
return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs)
class SQLiteParameterizedValueWrapper(ParameterizedValueWrapper, SQLLiteValueWrapper):
pass
class ParameterizedFunction(Function): class ParameterizedFunction(Function):
""" """
Class to monkey patch pypika.terms.Functions Class to monkey patch pypika.terms.Functions

View file

@ -431,6 +431,12 @@ class BackupGenerator:
elif self.backup_excludes: elif self.backup_excludes:
extra.extend([f"--ignore-table={self.db_name}.{table}" for table in self.backup_excludes]) extra.extend([f"--ignore-table={self.db_name}.{table}" for table in self.backup_excludes])
elif self.db_type == "sqlite":
if self.backup_includes:
extra.extend([f'"{table}"' for table in self.backup_includes])
elif self.backup_excludes:
click.secho("Excluding tables is not supported for SQLite", fg="yellow")
elif self.db_type == "postgres": elif self.db_type == "postgres":
if self.backup_includes: if self.backup_includes:
extra.extend([f'--table=public."{table}"' for table in self.backup_includes]) extra.extend([f'--table=public."{table}"' for table in self.backup_includes])

View file

@ -226,6 +226,9 @@ def insert_values_for_multiple_docs(all_contents):
(doctype, name, content, published, title, route) (doctype, name, content, published, title, route)
VALUES {} VALUES {}
ON CONFLICT("name", "doctype") DO NOTHING""".format(", ".join(batch_values)), ON CONFLICT("name", "doctype") DO NOTHING""".format(", ".join(batch_values)),
"sqlite": """INSERT OR IGNORE INTO `__global_search`
(doctype, name, content, published, title, route)
VALUES {} """.format(", ".join(batch_values)),
} }
) )
@ -447,6 +450,10 @@ def sync_value(value: dict):
`published`=%(published)s, `published`=%(published)s,
`title`=%(title)s, `title`=%(title)s,
`route`=%(route)s `route`=%(route)s
""",
"sqlite": """INSERT OR REPLACE INTO `__global_search`
(`doctype`, `name`, `content`, `published`, `title`, `route`)
VALUES (%(doctype)s, %(name)s, %(content)s, %(published)s, %(title)s, %(route)s)
""", """,
}, },
value, value,

View file

@ -44,6 +44,8 @@ def get_random(doctype: str, filters: dict | None = None, doc: bool = False):
"mariadb": f"""select name from `tab{doctype}` {condition} "mariadb": f"""select name from `tab{doctype}` {condition}
order by RAND() limit 1 offset 0""", order by RAND() limit 1 offset 0""",
"postgres": f"""select name from `tab{doctype}` {condition} "postgres": f"""select name from `tab{doctype}` {condition}
order by RANDOM() limit 1 offset 0""",
"sqlite": f"""select name from `tab{doctype}` {condition}
order by RANDOM() limit 1 offset 0""", order by RANDOM() limit 1 offset 0""",
} }
) )