← back to La Socrata Ingester

src/db.js

87 lines

import pg from 'pg';

// Connect to the realestate DB. Prefer DATABASE_URL; otherwise let libpq env vars
// (PGHOST/PGUSER/...) apply and just set the database name to REALESTATE_DB.
const { Pool } = pg;
export const pool = process.env.DATABASE_URL
  ? new Pool({ connectionString: process.env.DATABASE_URL })
  : new Pool({ database: process.env.REALESTATE_DB || 'realestate' });

export async function q(text, params) {
  const client = await pool.connect();
  try {
    return await client.query(text, params);
  } finally {
    client.release();
  }
}

// Generic chunked upsert.
//   table    — target table name
//   conflict — array of PK column names for ON CONFLICT
//   rows     — array of plain objects; keys are column names, one key may be `raw`
//              (stringified to jsonb). Columns are taken from the first row.
// Returns the number of rows sent.
export async function upsert(table, conflict, rows) {
  if (!rows.length) return 0;

  // Postgres rejects an INSERT ... ON CONFLICT that targets the same conflict key
  // twice in one statement ("cannot affect row a second time"). Some datasets
  // repeat a key within a single page (e.g. one code-enforcement apno or one film
  // permit_no spanning multiple rows), so collapse by conflict key first — last wins.
  if (rows.length > 1) {
    const seen = new Map();
    for (const r of rows) seen.set(conflict.map((k) => r[k]).join(''), r);
    if (seen.size !== rows.length) rows = [...seen.values()];
  }

  const cols = Object.keys(rows[0]);
  const updatable = cols.filter((c) => !conflict.includes(c));
  const setClause = updatable.length
    ? 'DO UPDATE SET ' + updatable.map((c) => `"${c}" = EXCLUDED."${c}"`).join(', ')
    : 'DO NOTHING';

  const JSONB = new Set(['raw', 'geom', 'attrs']); // columns stored as jsonb
  const CHUNK = 500;
  let sent = 0;
  for (let i = 0; i < rows.length; i += CHUNK) {
    const slice = rows.slice(i, i + CHUNK);
    const values = [];
    const tuples = slice.map((row) => {
      const ph = cols.map((c) => {
        let v = row[c];
        if (JSONB.has(c) && v != null && typeof v !== 'string') v = JSON.stringify(v);
        values.push(v === undefined ? null : v);
        return `$${values.length}${JSONB.has(c) ? '::jsonb' : ''}`;
      });
      return `(${ph.join(',')})`;
    });
    const sql =
      `INSERT INTO ${table} (${cols.map((c) => `"${c}"`).join(',')}) ` +
      `VALUES ${tuples.join(',')} ` +
      `ON CONFLICT (${conflict.map((c) => `"${c}"`).join(',')}) ${setClause}`;
    await q(sql, values);
    sent += slice.length;
  }
  return sent;
}

export async function getState(source) {
  const { rows } = await q('SELECT * FROM la_ingest_state WHERE source = $1', [source]);
  return rows[0] || null;
}

export async function setState(source, { dataset_id, last_cursor, rows_upserted, last_status }) {
  await q(
    `INSERT INTO la_ingest_state (source, dataset_id, last_cursor, last_run, rows_upserted, last_status)
     VALUES ($1,$2,$3, now(), $4, $5)
     ON CONFLICT (source) DO UPDATE SET
       dataset_id = EXCLUDED.dataset_id,
       last_cursor = COALESCE(EXCLUDED.last_cursor, la_ingest_state.last_cursor),
       last_run = EXCLUDED.last_run,
       rows_upserted = la_ingest_state.rows_upserted + EXCLUDED.rows_upserted,
       last_status = EXCLUDED.last_status`,
    [source, dataset_id, last_cursor, rows_upserted, last_status]
  );
}