초기 커밋: Docker/PostgreSQL 이관, 구글 OAuth 제거, Gitea 푸시 스크립트 추가

This commit is contained in:
king
2026-05-24 03:07:53 +09:00
commit 54707c99a3
23 changed files with 6933 additions and 0 deletions
View File
+76
View File
@@ -0,0 +1,76 @@
import os
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
router = APIRouter(tags=["auth"])
DEFAULT_ADMIN_EMAIL = os.getenv("DEFAULT_ADMIN_EMAIL", "king@dbxcorp.co.kr")
DEFAULT_ADMIN_NAME = os.getenv("DEFAULT_ADMIN_NAME", "Administrator")
def app_path(path: str = "/") -> str:
root_path = os.getenv("APP_ROOT_PATH", "").rstrip("/")
if path == "/":
return root_path or "/"
return f"{root_path}{path}"
def get_current_user(request: Request, db: Session = Depends(get_db)):
user_email = request.session.get("user_email")
if not user_email:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user = db.query(User).filter(User.email == user_email).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if not user.is_approved and not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is not approved by administrator")
return user
def get_admin_user(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user
def _login_as_default_admin(request: Request, db: Session) -> RedirectResponse:
email = DEFAULT_ADMIN_EMAIL
user = db.query(User).filter(User.email == email).first()
if not user:
user = User(email=email, name=DEFAULT_ADMIN_NAME, is_admin=True, is_approved=True)
db.add(user)
db.commit()
db.refresh(user)
request.session["user_email"] = email
return RedirectResponse(url=app_path("/"))
@router.get("/login")
async def login(request: Request, db: Session = Depends(get_db)):
return _login_as_default_admin(request, db)
@router.get("/login/test")
async def login_test(request: Request, db: Session = Depends(get_db)):
return _login_as_default_admin(request, db)
@router.get("/logout")
async def logout(request: Request):
request.session.pop("user_email", None)
return RedirectResponse(url=app_path("/"))
@router.get("/api/users/me")
async def read_users_me(current_user: User = Depends(get_current_user)):
return {"email": current_user.email, "name": current_user.name, "is_admin": current_user.is_admin}
+113
View File
@@ -0,0 +1,113 @@
import logging
import os
from urllib.parse import quote_plus
from sqlalchemy import create_engine, text
from sqlalchemy.orm import declarative_base, sessionmaker
logger = logging.getLogger(__name__)
def load_env_file(path: str = ".env"):
if not os.path.exists(path):
return
with open(path, "r", encoding="utf-8") as env_file:
for raw_line in env_file:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
load_env_file()
def _build_pg_url(db_name: str) -> str:
user = os.getenv("DB_USER", "")
password = os.getenv("DB_PASSWORD", "")
host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
return (
f"postgresql+psycopg2://{quote_plus(user)}:{quote_plus(password)}"
f"@{host}:{port}/{db_name}"
)
def _resolve_database_url() -> str:
explicit_url = os.getenv("DATABASE_URL")
if explicit_url:
return explicit_url
order_db = os.getenv("ORDER_DB_NAME")
if order_db and os.getenv("DB_HOST"):
return _build_pg_url(order_db)
return "sqlite:///./app.db"
DATABASE_URL = _resolve_database_url()
connect_args = {}
engine_kwargs = {}
if DATABASE_URL.startswith("sqlite"):
connect_args = {"check_same_thread": False, "timeout": 30}
else:
engine_kwargs.update(pool_pre_ping=True, pool_recycle=1800)
engine = create_engine(DATABASE_URL, connect_args=connect_args, **engine_kwargs)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# --- Secondary engines for the other PostgreSQL databases ----------------
# These are exposed for code that needs to query item / return / checklist
# databases. They are created lazily and only when PostgreSQL is configured.
_secondary_engines: dict[str, object] = {}
def get_secondary_engine(env_var: str):
"""Get (or create) an engine for ITEM_DB_NAME / RETURN_DB_NAME / CHECKLIST_DB_NAME."""
db_name = os.getenv(env_var)
if not db_name or not os.getenv("DB_HOST"):
return None
if env_var in _secondary_engines:
return _secondary_engines[env_var]
eng = create_engine(_build_pg_url(db_name), pool_pre_ping=True, pool_recycle=1800)
_secondary_engines[env_var] = eng
return eng
# Dependency for FastAPI to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def check_db_connection() -> tuple[bool, str]:
"""Ping the primary database. Returns (ok, message)."""
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return True, "ok"
except Exception as exc: # noqa: BLE001
return False, str(exc)
def log_db_startup_status() -> None:
safe_url = engine.url.render_as_string(hide_password=True)
ok, msg = check_db_connection()
if ok:
logger.warning("[DB] Connected: %s", safe_url)
else:
logger.error("[DB] Connection FAILED: %s -> %s", safe_url, msg)
+278
View File
@@ -0,0 +1,278 @@
from sqlalchemy import inspect, text
SEARCH_OPTIMIZATION_VERSION = "2026-04-24-search-v1"
PHONE_FTS_VERSION = "2026-04-24-phone-fts-v1"
ADDRESS_NORMALIZATION_VERSION = "2026-04-29-address-normalized-v1"
def _supports_trigram_fts(conn) -> bool:
try:
conn.execute(
text(
"CREATE VIRTUAL TABLE IF NOT EXISTS temp_fts_trigram_check "
"USING fts5(value, tokenize='trigram')"
)
)
conn.execute(text("DROP TABLE IF EXISTS temp_fts_trigram_check"))
return True
except Exception:
return False
def _setting_value(conn, key: str) -> str | None:
row = conn.execute(
text("SELECT value FROM settings WHERE key = :key"), {"key": key}
).fetchone()
return row[0] if row else None
def _set_setting(conn, key: str, value: str) -> None:
conn.execute(
text(
"INSERT INTO settings(key, value) VALUES (:key, :value) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
),
{"key": key, "value": value},
)
def ensure_search_optimizations(engine) -> None:
if engine.dialect.name != "sqlite":
ensure_standard_indexes(engine)
return
inspector = inspect(engine)
existing_columns = {column["name"] for column in inspector.get_columns("orders")}
with engine.begin() as conn:
needs_rebuild = _setting_value(conn, "search_optimization_version") != SEARCH_OPTIMIZATION_VERSION
if needs_rebuild:
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_ai"))
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_ad"))
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_au"))
conn.execute(text("DROP TABLE IF EXISTS orders_name_address_fts"))
if "recipient_phone_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_phone_normalized TEXT"))
if "recipient_mobile_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_mobile_normalized TEXT"))
if "tracking_number_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN tracking_number_normalized TEXT"))
if "address_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN address_normalized TEXT"))
added_customer_note = "customer_note" not in existing_columns
if added_customer_note:
conn.execute(text("ALTER TABLE orders ADD COLUMN customer_note TEXT"))
if "order_note" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN order_note TEXT"))
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_phone_normalized "
"ON orders(recipient_phone_normalized)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_mobile_normalized "
"ON orders(recipient_mobile_normalized)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_tracking_normalized "
"ON orders(tracking_number_normalized)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_address_normalized "
"ON orders(address_normalized)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_customer_note "
"ON orders(customer_note)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_order_note "
"ON orders(order_note)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_tracking_number_id "
"ON orders(tracking_number, id DESC)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_upload_date_id "
"ON orders(upload_date, id DESC)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_order_date_id "
"ON orders(order_date, id DESC)"
)
)
if needs_rebuild:
conn.execute(
text(
"UPDATE orders SET "
"recipient_phone_normalized = replace(replace(replace(replace(replace(coalesce(recipient_phone, ''), '-', ''), ' ', ''), '.', ''), ')', ''), '(', ''), "
"recipient_mobile_normalized = replace(replace(replace(replace(replace(coalesce(recipient_mobile, ''), '-', ''), ' ', ''), '.', ''), ')', ''), '(', ''), "
"tracking_number_normalized = upper(replace(replace(replace(replace(coalesce(tracking_number, ''), '-', ''), ' ', ''), '/', ''), '.', ''))"
)
)
if added_customer_note:
conn.execute(
text(
"UPDATE orders SET customer_note = note "
"WHERE note IS NOT NULL AND note != ''"
)
)
address_normalization_rebuild = (
_setting_value(conn, "address_normalization_version") != ADDRESS_NORMALIZATION_VERSION
)
if address_normalization_rebuild:
conn.execute(
text(
"UPDATE orders SET address_normalized = "
"trim(coalesce(address, ''), "
"' ' || char(9) || char(10) || char(13) || char(160) || "
"char(8203) || char(8204) || char(8205) || char(65279))"
)
)
_set_setting(conn, "address_normalization_version", ADDRESS_NORMALIZATION_VERSION)
tokenizer = "trigram" if _supports_trigram_fts(conn) else "unicode61"
conn.execute(
text(
"CREATE VIRTUAL TABLE IF NOT EXISTS orders_name_address_fts "
"USING fts5("
"recipient_name, address, "
"content='orders', content_rowid='id', "
f"tokenize='{tokenizer}'"
")"
)
)
if needs_rebuild:
conn.execute(text("INSERT INTO orders_name_address_fts(orders_name_address_fts) VALUES('rebuild')"))
_set_setting(conn, "search_optimization_version", SEARCH_OPTIMIZATION_VERSION)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_fts_ai AFTER INSERT ON orders BEGIN "
"INSERT INTO orders_name_address_fts(rowid, recipient_name, address) "
"VALUES (new.id, new.recipient_name, new.address); "
"END"
)
)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_fts_ad AFTER DELETE ON orders BEGIN "
"INSERT INTO orders_name_address_fts(orders_name_address_fts, rowid, recipient_name, address) "
"VALUES('delete', old.id, old.recipient_name, old.address); "
"END"
)
)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_fts_au AFTER UPDATE ON orders BEGIN "
"INSERT INTO orders_name_address_fts(orders_name_address_fts, rowid, recipient_name, address) "
"VALUES('delete', old.id, old.recipient_name, old.address); "
"INSERT INTO orders_name_address_fts(rowid, recipient_name, address) "
"VALUES (new.id, new.recipient_name, new.address); "
"END"
)
)
phone_fts_rebuild = _setting_value(conn, "phone_fts_version") != PHONE_FTS_VERSION
if phone_fts_rebuild:
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_ai"))
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_ad"))
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_au"))
conn.execute(text("DROP TABLE IF EXISTS orders_phone_fts"))
conn.execute(
text(
"CREATE VIRTUAL TABLE IF NOT EXISTS orders_phone_fts "
"USING fts5("
"recipient_phone_normalized, recipient_mobile_normalized, "
"content='orders', content_rowid='id', tokenize='trigram'"
")"
)
)
if phone_fts_rebuild:
conn.execute(text("INSERT INTO orders_phone_fts(orders_phone_fts) VALUES('rebuild')"))
_set_setting(conn, "phone_fts_version", PHONE_FTS_VERSION)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_ai AFTER INSERT ON orders BEGIN "
"INSERT INTO orders_phone_fts(rowid, recipient_phone_normalized, recipient_mobile_normalized) "
"VALUES (new.id, new.recipient_phone_normalized, new.recipient_mobile_normalized); "
"END"
)
)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_ad AFTER DELETE ON orders BEGIN "
"INSERT INTO orders_phone_fts(orders_phone_fts, rowid, recipient_phone_normalized, recipient_mobile_normalized) "
"VALUES('delete', old.id, old.recipient_phone_normalized, old.recipient_mobile_normalized); "
"END"
)
)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_au AFTER UPDATE ON orders BEGIN "
"INSERT INTO orders_phone_fts(orders_phone_fts, rowid, recipient_phone_normalized, recipient_mobile_normalized) "
"VALUES('delete', old.id, old.recipient_phone_normalized, old.recipient_mobile_normalized); "
"INSERT INTO orders_phone_fts(rowid, recipient_phone_normalized, recipient_mobile_normalized) "
"VALUES (new.id, new.recipient_phone_normalized, new.recipient_mobile_normalized); "
"END"
)
)
def ensure_standard_indexes(engine) -> None:
inspector = inspect(engine)
existing_columns = {column["name"] for column in inspector.get_columns("orders")}
with engine.begin() as conn:
if "recipient_phone_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_phone_normalized VARCHAR"))
if "recipient_mobile_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_mobile_normalized VARCHAR"))
if "tracking_number_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN tracking_number_normalized VARCHAR"))
if "address_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN address_normalized VARCHAR"))
added_customer_note = "customer_note" not in existing_columns
if added_customer_note:
conn.execute(text("ALTER TABLE orders ADD COLUMN customer_note VARCHAR"))
if "order_note" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN order_note VARCHAR"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_phone_normalized ON orders(recipient_phone_normalized)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_mobile_normalized ON orders(recipient_mobile_normalized)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_tracking_normalized ON orders(tracking_number_normalized)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_address_normalized ON orders(address_normalized)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_customer_note ON orders(customer_note)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_order_note ON orders(order_note)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_order_date_id ON orders(order_date, id DESC)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_upload_date_id ON orders(upload_date, id DESC)"))
if added_customer_note:
conn.execute(
text(
"UPDATE orders SET customer_note = note "
"WHERE note IS NOT NULL AND note != ''"
)
)
+43
View File
@@ -0,0 +1,43 @@
import csv
import json
import os
from datetime import datetime
from zoneinfo import ZoneInfo
LOG_DIR = "logs"
EVENT_LOG_PATH = os.path.join(LOG_DIR, "event_log.csv")
LOG_COLUMNS = ["date", "time", "user", "action", "status", "details"]
KST = ZoneInfo("Asia/Seoul")
def user_label(user) -> str:
if not user:
return ""
return getattr(user, "email", None) or getattr(user, "name", None) or str(getattr(user, "id", ""))
def log_event(user, action: str, status: str = "success", details: dict | str | None = None):
os.makedirs(LOG_DIR, exist_ok=True)
now = datetime.now(KST)
file_exists = os.path.exists(EVENT_LOG_PATH)
details_text = ""
if isinstance(details, dict):
details_text = json.dumps(details, ensure_ascii=False)
elif details is not None:
details_text = str(details)
with open(EVENT_LOG_PATH, "a", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=LOG_COLUMNS)
if not file_exists or os.path.getsize(EVENT_LOG_PATH) == 0:
writer.writeheader()
writer.writerow(
{
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"user": user_label(user),
"action": action,
"status": status,
"details": details_text,
}
)
+61
View File
@@ -0,0 +1,61 @@
from fastapi import FastAPI, Depends, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from starlette.middleware.sessions import SessionMiddleware
import logging
import os
from app.database import engine, Base, check_db_connection, log_db_startup_status
from app.database_maintenance import ensure_search_optimizations
from app.routers import orders, settings
from app.auth import router as auth_router
logging.basicConfig(level=logging.INFO)
APP_ROOT_PATH = os.getenv("APP_ROOT_PATH", "").rstrip("/")
# Verify DB connectivity at startup (log only — do not crash so the
# container can stay up and report status via /health/db).
log_db_startup_status()
# Create all DB tables
Base.metadata.create_all(bind=engine)
ensure_search_optimizations(engine)
app = FastAPI(title="Order Management System", root_path=APP_ROOT_PATH)
@app.get("/health/db")
async def health_db():
ok, msg = check_db_connection()
status_code = 200 if ok else 503
return JSONResponse({"ok": ok, "detail": msg}, status_code=status_code)
# Session middleware for OAuth
# In production, use a secure secret key from environment variables
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "super-secret-key"))
# Include routers
app.include_router(auth_router)
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
# Mount static files for frontend
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", response_class=HTMLResponse)
async def read_root():
# Return the main index.html file
if os.path.exists("static/index.html"):
with open("static/index.html", "r", encoding="utf-8") as f:
content = f.read()
content = content.replace("__APP_BASE_PATH__", APP_ROOT_PATH)
content = content.replace('src="/static/', f'src="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'src="/static/')
content = content.replace('href="/static/', f'href="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'href="/static/')
content = content.replace("window.location.href='/login", f"window.location.href='{APP_ROOT_PATH}/login")
content = content.replace('window.location.href="/login', f'window.location.href="{APP_ROOT_PATH}/login')
content = content.replace("window.location.href='/logout", f"window.location.href='{APP_ROOT_PATH}/logout")
content = content.replace('window.location.href="/logout', f'window.location.href="{APP_ROOT_PATH}/logout')
return HTMLResponse(content=content)
return HTMLResponse(content="<h1>Welcome. static/index.html is missing.</h1>")
+54
View File
@@ -0,0 +1,54 @@
from sqlalchemy import Column, Integer, String, Boolean
from .database import Base
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
order_date = Column(String, nullable=True) # 주문날짜
sequence_num = Column(String, nullable=True) # 번호
order_no = Column(String, nullable=True) # 주문번호
order_no_mall = Column(String, nullable=True) # 주문번호(쇼핑몰)
recipient_name = Column(String, index=True, nullable=True) # 수령인명
product_code = Column(String, nullable=True) # 상품코드
product_name = Column(String, nullable=True) # 상품명
order_quantity = Column(String, nullable=True) # 주문수량
address = Column(String, index=True, nullable=True) # 주소
address_normalized = Column(String, index=True, nullable=True)
postal_code = Column(String, nullable=True) # 우편번호
recipient_phone = Column(String, nullable=True) # 수령인 전화번호 (일반전화)
recipient_mobile = Column(String, index=True, nullable=True) # 수령인 휴대폰
recipient_phone_normalized = Column(String, index=True, nullable=True)
recipient_mobile_normalized = Column(String, index=True, nullable=True)
delivery_memo = Column(String, nullable=True) # 배송시 요구사항
tracking_number = Column(String, index=True, nullable=True) # 송장번호
tracking_number_normalized = Column(String, index=True, nullable=True)
vendor = Column(String, nullable=True) # 발주처
order_list_1 = Column(String, nullable=True) # 주문목록
order_list_2 = Column(String, nullable=True) # 주문목록2
note = Column(String, index=True) # 비고 (옵션)
customer_note = Column(String, index=True, nullable=True) # 고객 메모
order_note = Column(String, index=True, nullable=True) # 주문 메모
upload_date = Column(String, nullable=True) # 추가된 날짜 및 시간 (yyyy-MM-dd HH:mm:ss)
class User(Base):
"""
Users table for Google Workspace authentication.
Only approved users can access the system.
"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
name = Column(String, nullable=True)
is_admin = Column(Boolean, default=False)
is_approved = Column(Boolean, default=False)
class Setting(Base):
"""
Application settings (e.g., Excel export path)
"""
__tablename__ = "settings"
key = Column(String, primary_key=True, index=True)
value = Column(String, nullable=True)
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
import re
_NON_DIGIT_RE = re.compile(r"\D+")
_NON_ALNUM_RE = re.compile(r"[^0-9A-Za-z]+")
def normalize_phone(value: str | None) -> str | None:
if not value:
return None
normalized = _NON_DIGIT_RE.sub("", str(value))
return normalized or None
def normalize_tracking(value: str | None) -> str | None:
if not value:
return None
normalized = _NON_ALNUM_RE.sub("", str(value)).upper()
return normalized or None
def fts_phrase(value: str) -> str:
escaped = str(value).strip().replace('"', '""')
return f'"{escaped}"'
def can_use_trigram_fts(value: str | None) -> bool:
if not value:
return False
return len(str(value).strip()) >= 3
def prefix_upper_bound(value: str) -> str | None:
if not value:
return None
last = value[-1]
if last == "\uffff":
return None
return value[:-1] + chr(ord(last) + 1)