This commit is contained in:
412
db/polygons.js
Normal file
412
db/polygons.js
Normal file
@@ -0,0 +1,412 @@
|
||||
const Database = require("better-sqlite3");
|
||||
const path = require("path");
|
||||
|
||||
const dbPath = path.join(__dirname, "..", "data", "polygons.db");
|
||||
const db = new Database(dbPath);
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS geometries (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
draw_geometry TEXT NOT NULL,
|
||||
binding TEXT,
|
||||
time_start INTEGER,
|
||||
time_end INTEGER,
|
||||
bbox_min_lng REAL,
|
||||
bbox_min_lat REAL,
|
||||
bbox_max_lng REAL,
|
||||
bbox_max_lat REAL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE,
|
||||
description TEXT,
|
||||
type_id TEXT NOT NULL DEFAULT 'country',
|
||||
status INTEGER DEFAULT 1,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS entity_geometries (
|
||||
entity_id TEXT NOT NULL,
|
||||
geometry_id TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (entity_id, geometry_id),
|
||||
FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (geometry_id) REFERENCES geometries(id) ON DELETE CASCADE
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS sections (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
user_id TEXT,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS section_states (
|
||||
section_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'editing',
|
||||
head_commit_id TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
locked_by TEXT,
|
||||
locked_at TEXT,
|
||||
lock_expires_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS section_commits (
|
||||
id TEXT PRIMARY KEY,
|
||||
section_id TEXT NOT NULL,
|
||||
parent_commit_id TEXT,
|
||||
commit_no INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual',
|
||||
restored_from_commit_id TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
title TEXT,
|
||||
note TEXT,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
snapshot_hash TEXT,
|
||||
FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_commit_id) REFERENCES section_commits(id),
|
||||
FOREIGN KEY (restored_from_commit_id) REFERENCES section_commits(id)
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS section_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
section_id TEXT NOT NULL,
|
||||
commit_id TEXT NOT NULL,
|
||||
submitted_by TEXT NOT NULL,
|
||||
submitted_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
reviewed_by TEXT,
|
||||
reviewed_at TEXT,
|
||||
review_note TEXT,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
snapshot_hash TEXT,
|
||||
FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (commit_id) REFERENCES section_commits(id)
|
||||
)
|
||||
`).run();
|
||||
|
||||
ensureColumn("entities", "status", "INTEGER DEFAULT 1");
|
||||
ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'");
|
||||
ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("geometries", "binding", "TEXT");
|
||||
ensureColumn("sections", "user_id", "TEXT");
|
||||
dropEntityDeprecatedColumnsIfExists();
|
||||
dropGeometryDeprecatedColumnsIfExists();
|
||||
migrateLegacyGeometryTypeTokens();
|
||||
|
||||
db.prepare(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_slug
|
||||
ON entities(slug)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_name
|
||||
ON entities(name)
|
||||
`).run();
|
||||
|
||||
db.prepare(`DROP INDEX IF EXISTS idx_entity_geometries_geometry_id`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_geometries_geometry_id
|
||||
ON entity_geometries(geometry_id)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_geometries_entity_id
|
||||
ON entity_geometries(entity_id)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE INDEX IF NOT EXISTS idx_section_states_status
|
||||
ON section_states(status, updated_at)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_section_commits_no
|
||||
ON section_commits(section_id, commit_no)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE INDEX IF NOT EXISTS idx_section_commits_section_time
|
||||
ON section_commits(section_id, created_at)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE INDEX IF NOT EXISTS idx_section_submissions_section_status
|
||||
ON section_submissions(section_id, status, submitted_at)
|
||||
`).run();
|
||||
|
||||
module.exports = db;
|
||||
|
||||
function ensureColumn(tableName, columnName, columnDefinition) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
const hasColumn = columns.some((column) => column.name === columnName);
|
||||
if (hasColumn) return;
|
||||
db.prepare(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDefinition}`).run();
|
||||
}
|
||||
|
||||
function dropGeometryDeprecatedColumnsIfExists() {
|
||||
db.prepare(`DROP INDEX IF EXISTS idx_geometries_kind`).run();
|
||||
|
||||
const columns = db.prepare(`PRAGMA table_info(geometries)`).all();
|
||||
const hasKind = columns.some((column) => column.name === "kind");
|
||||
const hasLineMode = columns.some((column) => column.name === "line_mode");
|
||||
if (!hasKind && !hasLineMode) return;
|
||||
rebuildGeometriesTableToCurrentSchema(columns);
|
||||
}
|
||||
|
||||
function dropEntityDeprecatedColumnsIfExists() {
|
||||
const columns = db.prepare(`PRAGMA table_info(entities)`).all();
|
||||
const hasKind = columns.some((column) => column.name === "kind");
|
||||
if (!hasKind) return;
|
||||
rebuildEntitiesTableToCurrentSchema(columns);
|
||||
}
|
||||
|
||||
function rebuildEntitiesTableToCurrentSchema(existingColumns = null) {
|
||||
const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1;
|
||||
const columns = existingColumns || db.prepare(`PRAGMA table_info(entities)`).all();
|
||||
const hasSlug = columns.some((column) => column.name === "slug");
|
||||
const hasDescription = columns.some((column) => column.name === "description");
|
||||
const hasTypeId = columns.some((column) => column.name === "type_id");
|
||||
const hasStatus = columns.some((column) => column.name === "status");
|
||||
const hasIsDeleted = columns.some((column) => column.name === "is_deleted");
|
||||
const hasCreatedAt = columns.some((column) => column.name === "created_at");
|
||||
const hasUpdatedAt = columns.some((column) => column.name === "updated_at");
|
||||
|
||||
const slugSelect = hasSlug ? "slug" : "NULL";
|
||||
const descriptionSelect = hasDescription ? "description" : "NULL";
|
||||
const typeIdSelect = hasTypeId ? "type_id" : "'country'";
|
||||
const statusSelect = hasStatus ? "status" : "1";
|
||||
const isDeletedSelect = hasIsDeleted ? "is_deleted" : "0";
|
||||
const createdAtSelect = hasCreatedAt ? "created_at" : "NULL";
|
||||
const updatedAtSelect = hasUpdatedAt ? "updated_at" : "NULL";
|
||||
|
||||
if (foreignKeysEnabled) {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
}
|
||||
|
||||
try {
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(`DROP TABLE IF EXISTS entities_new`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE entities_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE,
|
||||
description TEXT,
|
||||
type_id TEXT NOT NULL DEFAULT 'country',
|
||||
status INTEGER DEFAULT 1,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO entities_new (
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
type_id,
|
||||
status,
|
||||
is_deleted,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
${slugSelect},
|
||||
${descriptionSelect},
|
||||
${typeIdSelect},
|
||||
${statusSelect},
|
||||
${isDeletedSelect},
|
||||
${createdAtSelect},
|
||||
${updatedAtSelect}
|
||||
FROM entities
|
||||
`).run();
|
||||
|
||||
db.prepare(`DROP TABLE entities`).run();
|
||||
db.prepare(`ALTER TABLE entities_new RENAME TO entities`).run();
|
||||
});
|
||||
|
||||
tx();
|
||||
} finally {
|
||||
if (foreignKeysEnabled) {
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildGeometriesTableToCurrentSchema(existingColumns = null) {
|
||||
const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1;
|
||||
const columns = existingColumns || db.prepare(`PRAGMA table_info(geometries)`).all();
|
||||
const hasType = columns.some((column) => column.name === "type");
|
||||
const hasIsDeleted = columns.some((column) => column.name === "is_deleted");
|
||||
const hasBinding = columns.some((column) => column.name === "binding");
|
||||
const hasTimeStart = columns.some((column) => column.name === "time_start");
|
||||
const hasTimeEnd = columns.some((column) => column.name === "time_end");
|
||||
const hasBBoxMinLng = columns.some((column) => column.name === "bbox_min_lng");
|
||||
const hasBBoxMinLat = columns.some((column) => column.name === "bbox_min_lat");
|
||||
const hasBBoxMaxLng = columns.some((column) => column.name === "bbox_max_lng");
|
||||
const hasBBoxMaxLat = columns.some((column) => column.name === "bbox_max_lat");
|
||||
const hasCreatedAt = columns.some((column) => column.name === "created_at");
|
||||
const hasUpdatedAt = columns.some((column) => column.name === "updated_at");
|
||||
|
||||
const typeSelect = hasType ? "type" : "NULL";
|
||||
const isDeletedSelect = hasIsDeleted ? "is_deleted" : "0";
|
||||
const bindingSelect = hasBinding ? "binding" : "NULL";
|
||||
const timeStartSelect = hasTimeStart ? "time_start" : "NULL";
|
||||
const timeEndSelect = hasTimeEnd ? "time_end" : "NULL";
|
||||
const bboxMinLngSelect = hasBBoxMinLng ? "bbox_min_lng" : "NULL";
|
||||
const bboxMinLatSelect = hasBBoxMinLat ? "bbox_min_lat" : "NULL";
|
||||
const bboxMaxLngSelect = hasBBoxMaxLng ? "bbox_max_lng" : "NULL";
|
||||
const bboxMaxLatSelect = hasBBoxMaxLat ? "bbox_max_lat" : "NULL";
|
||||
const createdAtSelect = hasCreatedAt ? "created_at" : "NULL";
|
||||
const updatedAtSelect = hasUpdatedAt ? "updated_at" : "NULL";
|
||||
if (foreignKeysEnabled) {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
}
|
||||
|
||||
try {
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(`DROP TABLE IF EXISTS geometries_new`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE geometries_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
draw_geometry TEXT NOT NULL,
|
||||
binding TEXT,
|
||||
time_start INTEGER,
|
||||
time_end INTEGER,
|
||||
bbox_min_lng REAL,
|
||||
bbox_min_lat REAL,
|
||||
bbox_max_lng REAL,
|
||||
bbox_max_lat REAL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO geometries_new (
|
||||
id,
|
||||
type,
|
||||
is_deleted,
|
||||
draw_geometry,
|
||||
binding,
|
||||
time_start,
|
||||
time_end,
|
||||
bbox_min_lng,
|
||||
bbox_min_lat,
|
||||
bbox_max_lng,
|
||||
bbox_max_lat,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
${typeSelect},
|
||||
${isDeletedSelect},
|
||||
draw_geometry,
|
||||
${bindingSelect},
|
||||
${timeStartSelect},
|
||||
${timeEndSelect},
|
||||
${bboxMinLngSelect},
|
||||
${bboxMinLatSelect},
|
||||
${bboxMaxLngSelect},
|
||||
${bboxMaxLatSelect},
|
||||
${createdAtSelect},
|
||||
${updatedAtSelect}
|
||||
FROM geometries
|
||||
`).run();
|
||||
|
||||
db.prepare(`DROP TABLE geometries`).run();
|
||||
db.prepare(`ALTER TABLE geometries_new RENAME TO geometries`).run();
|
||||
});
|
||||
|
||||
tx();
|
||||
} finally {
|
||||
if (foreignKeysEnabled) {
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacyGeometryTypeTokens() {
|
||||
const geometryColumns = db.prepare(`PRAGMA table_info(geometries)`).all();
|
||||
const hasType = geometryColumns.some((column) => column.name === "type");
|
||||
if (!hasType) return;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
const legacyRows = db.prepare(`
|
||||
SELECT
|
||||
g.id,
|
||||
(
|
||||
SELECT e.type_id
|
||||
FROM entity_geometries eg
|
||||
JOIN entities e
|
||||
ON e.id = eg.entity_id
|
||||
AND e.is_deleted = 0
|
||||
WHERE eg.geometry_id = g.id
|
||||
ORDER BY eg.rowid ASC
|
||||
LIMIT 1
|
||||
) AS primary_entity_type
|
||||
FROM geometries g
|
||||
WHERE g.type IS NULL
|
||||
OR TRIM(g.type) = ''
|
||||
OR LOWER(TRIM(g.type)) IN ('line', 'path')
|
||||
`).all();
|
||||
|
||||
const updateTypeStmt = db.prepare(`
|
||||
UPDATE geometries
|
||||
SET type = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
for (const row of legacyRows) {
|
||||
const semanticType = normalizeText(row.primary_entity_type);
|
||||
updateTypeStmt.run(semanticType, row.id);
|
||||
}
|
||||
});
|
||||
|
||||
tx();
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
if (value === undefined || value === null) return null;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
Reference in New Issue
Block a user