601451f22b
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.
New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.
compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).
Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.
Co-Authored-By: Claude Code <noreply@anthropic.com>
135 lines
4.9 KiB
Python
135 lines
4.9 KiB
Python
"""Conservative PostgreSQL DB-API bridge for the existing SQLite-shaped application.
|
|
|
|
The row adapter intentionally supports BOTH row['column'] and row[0], because
|
|
several existing queue paths use positional aggregate results while API paths
|
|
serialize rows as dictionaries. PostgreSQL data migration remains explicit.
|
|
"""
|
|
from __future__ import annotations
|
|
import re
|
|
|
|
class PGRow(dict):
|
|
"""Mapping-compatible row with SQLite-style positional indexing."""
|
|
__slots__ = ('_values',)
|
|
|
|
def __init__(self, columns, values):
|
|
values = tuple(values)
|
|
super().__init__(zip(columns, values))
|
|
self._values = values
|
|
|
|
def __getitem__(self, key):
|
|
if isinstance(key, int):
|
|
return self._values[key]
|
|
return super().__getitem__(key)
|
|
|
|
|
|
def row_factory(cursor):
|
|
columns = [col.name for col in (cursor.description or ())]
|
|
return lambda values: PGRow(columns, values)
|
|
|
|
|
|
def placeholders(sql: str) -> str:
|
|
"""Translate ? placeholders without modifying quoted strings or comments."""
|
|
out=[]; i=0; state='code'
|
|
while i<len(sql):
|
|
ch=sql[i]; nxt=sql[i+1] if i+1<len(sql) else ''
|
|
if state=='code':
|
|
if ch in ("'",'"'):
|
|
state=ch;out.append(ch)
|
|
elif ch=='-' and nxt=='-':
|
|
state='line';out.extend((ch,nxt));i+=1
|
|
elif ch=='/' and nxt=='*':
|
|
state='block';out.extend((ch,nxt));i+=1
|
|
elif ch=='?':out.append('%s')
|
|
else:out.append(ch)
|
|
elif state in ("'",'"'):
|
|
out.append(ch)
|
|
if ch==state:
|
|
if nxt==state:out.append(nxt);i+=1
|
|
else:state='code'
|
|
elif state=='line':
|
|
out.append(ch)
|
|
if ch=='\n':state='code'
|
|
elif state=='block':
|
|
out.append(ch)
|
|
if ch=='*' and nxt=='/':out.append(nxt);i+=1;state='code'
|
|
i+=1
|
|
return ''.join(out)
|
|
|
|
|
|
def statements(script: str):
|
|
"""Split migration DDL at top-level semicolons, not inside quoted literals."""
|
|
start=0;i=0;state='code'
|
|
while i<len(script):
|
|
ch=script[i];nxt=script[i+1] if i+1<len(script) else ''
|
|
if state=='code':
|
|
if ch in ("'",'"'):state=ch
|
|
elif ch=='-' and nxt=='-':state='line';i+=1
|
|
elif ch=='/' and nxt=='*':state='block';i+=1
|
|
elif ch==';':
|
|
candidate=script[start:i].strip()
|
|
if candidate:yield candidate
|
|
start=i+1
|
|
elif state in ("'",'"'):
|
|
if ch==state:
|
|
if nxt==state:i+=1
|
|
else:state='code'
|
|
elif state=='line':
|
|
if ch=='\n':state='code'
|
|
elif state=='block':
|
|
if ch=='*' and nxt=='/':i+=1;state='code'
|
|
i+=1
|
|
candidate=script[start:].strip()
|
|
if candidate:yield candidate
|
|
|
|
|
|
def normalize(sql: str) -> str:
|
|
sql=sql.strip()
|
|
if re.match(r'^INSERT\s+OR\s+IGNORE\s+INTO\b',sql,re.I):
|
|
sql=re.sub(r'^INSERT\s+OR\s+IGNORE\s+INTO\b','INSERT INTO',sql,flags=re.I)
|
|
sql=sql.rstrip(';')+' ON CONFLICT DO NOTHING'
|
|
return placeholders(sql)
|
|
|
|
|
|
class PGConnection:
|
|
def __init__(self,dsn):
|
|
import psycopg
|
|
self.connection=psycopg.connect(dsn,row_factory=row_factory,connect_timeout=5)
|
|
self.total_changes=0
|
|
|
|
def execute(self,sql,params=()):
|
|
stripped=sql.strip()
|
|
if stripped.upper().startswith('PRAGMA TABLE_INFO('):
|
|
table=stripped.split('(',1)[1].split(')',1)[0].strip().strip('"\'')
|
|
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',table):raise ValueError('Invalid table name')
|
|
return self.connection.execute('SELECT column_name AS name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name=%s',(table,))
|
|
if stripped.upper()=='BEGIN IMMEDIATE':
|
|
# Serialize short queue claim/limit transactions, not the worker execution.
|
|
# This prevents per-site races between concurrent enqueuers and claimers.
|
|
return self.connection.execute('SELECT pg_advisory_xact_lock(823947231)')
|
|
if stripped.upper().startswith('PRAGMA '):return _EmptyResult()
|
|
cursor=self.connection.execute(normalize(sql),params)
|
|
if cursor.rowcount and cursor.rowcount>0 and stripped.split(None,1)[0].upper() in ('UPDATE','INSERT','DELETE'):
|
|
self.total_changes+=cursor.rowcount
|
|
return cursor
|
|
|
|
def executescript(self,script):
|
|
for statement in statements(script):
|
|
if not statement.lstrip().startswith('--') or '\n' in statement:
|
|
self.execute(statement)
|
|
|
|
def __enter__(self):return self
|
|
def __exit__(self,typ,val,tb):
|
|
try:
|
|
if typ:self.connection.rollback()
|
|
else:self.connection.commit()
|
|
finally:self.connection.close()
|
|
return False
|
|
|
|
class _EmptyResult:
|
|
rowcount=0
|
|
def fetchone(self):return None
|
|
def fetchall(self):return []
|
|
|
|
|
|
def connect(dsn):return PGConnection(dsn)
|