惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

G
Google Developers Blog
T
Troy Hunt's Blog
博客园 - 【当耐特】
N
Netflix TechBlog - Medium
V
V2EX
I
InfoQ
量子位
Hugging Face - Blog
Hugging Face - Blog
The Register - Security
The Register - Security
J
Java Code Geeks
V
Visual Studio Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
Security @ Cisco Blogs
爱范儿
爱范儿
Hacker News: Ask HN
Hacker News: Ask HN
Recent Commits to openclaw:main
Recent Commits to openclaw:main
B
Blog
Apple Machine Learning Research
Apple Machine Learning Research
V2EX - 技术
V2EX - 技术
罗磊的独立博客
S
Security Affairs
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
IT之家
IT之家
O
OpenAI News
W
WeLiveSecurity
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
GbyAI
GbyAI
The Hacker News
The Hacker News
Attack and Defense Labs
Attack and Defense Labs
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Microsoft Azure Blog
Microsoft Azure Blog
F
Fortinet All Blogs
Schneier on Security
Schneier on Security
雷峰网
雷峰网
Scott Helme
Scott Helme
B
Blog RSS Feed
有赞技术团队
有赞技术团队
Recent Announcements
Recent Announcements
L
LangChain Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Y
Y Combinator Blog
Help Net Security
Help Net Security
NISL@THU
NISL@THU
A
About on SuperTechFans
L
LINUX DO - 最新话题
博客园 - 司徒正美
博客园 - 聂微东
博客园 - 三生石上(FineUI控件)
Spread Privacy
Spread Privacy
P
Proofpoint News Feed

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Ad-Hoc Video Analytics with DuckDB on Parquet Exports from Production SQLite
ahmet gedik · 2026-06-29 · via DEV Community

Every couple of hours, a cron job on a cheap LiteSpeed shared host wakes up, hits the YouTube Data API for eight regions — US, GB, DE, FR, IN, BR, AU, CA — and writes trending-video metadata into a single SQLite database. That is the entire ingestion pipeline behind TrendVidStream, a multi-region video streaming discovery site I run. PHP 8.4 renders the pages, SQLite FTS5 powers search, and deploys happen over plain FTP because that is what the hosting gives you. It works, it is boring, and it costs almost nothing.

What this setup does not handle gracefully is me, at 11pm, asking questions like: "Which videos trended in four or more regions this week, how fast did they climb, and is the music category quietly eating everything else?" Those are analytical questions — full scans, window functions, cross-snapshot joins. Running them against the production SQLite file means either hammering a shared-host CPU that is also serving traffic, or copying a multi-hundred-megabyte .db file to my laptop every time I get curious.

The fix that stuck: a nightly export of the hot tables to hive-partitioned Parquet, pulled down over the same FTP channel the deploys already use, and queried locally with DuckDB. The whole thing is about 120 lines of PHP and SQL, and it turned "I wonder if..." questions from a 20-minute chore into a 4-second query.

Why not just query SQLite directly

SQLite is genuinely great at what the site needs: point lookups by video ID, FTS5 full-text search, small ordered scans for category pages. It is a row store with a single-writer model, and the access patterns of a discovery site fit that perfectly.

Analytical queries are a different animal:

  • They scan everything. "View-count delta per video per region over 14 days" touches every row in the window. A row store reads entire rows even when you need three columns out of eighteen.
  • They run on the wrong machine. Shared hosting gives you a slice of a CPU and a 180-second execution cap. A 40-second aggregate query is both slow and rude to your own visitors.
  • They want history. The production table is a current state table — each fetch upserts the latest stats. The moment you ask "how fast is this climbing," you need snapshots, and bolting a history table onto the production schema bloats the file that FTS5 and the page cache live in.
  • They are exploratory. Half my queries are dead ends. Dead ends belong on a laptop, not on a box serving requests.

DuckDB is the obvious counterpart: an in-process columnar engine that reads Parquet natively, does proper window functions and grouping sets, and installs with pip install duckdb or a single static binary. No server, no cluster, no infrastructure to babysit — which matters when your entire ops story is cron plus FTP.

First contact: pointing DuckDB at the SQLite file

Before building any export pipeline, it is worth knowing that DuckDB can read SQLite databases directly through its sqlite extension. For one-off exploration against a copied-down .db file, this is all you need:

INSTALL sqlite;
LOAD sqlite;
ATTACH 'app.db' AS prod (TYPE sqlite, READ_ONLY);

SELECT region, count(*) AS videos, max(fetched_at) AS last_fetch
FROM prod.videos
GROUP BY region
ORDER BY videos DESC;

This works, and it is how I sanity-check the export pipeline itself. But as a daily workflow it has two problems. First, you are still shipping the whole database file around — mine is dominated by FTS5 index pages and description text that analytics never touches. Second, SQLite's type affinity leaks through: my timestamps are stored as TEXT (2026-06-12 14:20:01), integers that were ever written as strings stay strings, and every query ends up wrapped in casts.

Parquet snapshots solve both. You project only the analytical columns, cast once at export time, and get real types plus columnar compression. My daily snapshot of ~40,000 rows across eight regions lands at about 2.5 MB zstd-compressed — small enough that pulling it over FTP is a non-event.

The export cron, in PHP

The site already has a cron entry point that loops over regions, so the export hangs off the end of that. PHP does not have a native Parquet writer worth using, and I did not want one — the trick is to ship the DuckDB CLI binary (a single ~40 MB static executable) alongside the app and let PHP orchestrate it. The CLI reads the SQLite file through the same extension shown above and writes partitioned Parquet in one COPY statement:

<?php
declare(strict_types=1);

// cron/export_parquet.php — runs nightly after the last fetch cycle

const DB_PATH   = __DIR__ . '/../data/app.db';
const EXPORT_DIR = __DIR__ . '/../data/exports';
const DUCKDB_BIN = __DIR__ . '/../bin/duckdb';

$snapshotDate = new DateTimeImmutable('today')->format('Y-m-d'); // PHP 8.4: new in expression

$sql = <<<SQL
INSTALL sqlite; LOAD sqlite;
ATTACH '%s' AS prod (TYPE sqlite, READ_ONLY);
COPY (
    SELECT
        video_id,
        title,
        channel_id,
        channel_title,
        category_id,
        region,
        CAST(view_count AS BIGINT)  AS view_count,
        CAST(like_count AS BIGINT)  AS like_count,
        strptime(published_at, '%%Y-%%m-%%d %%H:%%M:%%S') AS published_at,
        strptime(fetched_at,   '%%Y-%%m-%%d %%H:%%M:%%S') AS fetched_at,
        DATE '%s' AS snapshot_date
    FROM prod.videos
    WHERE fetched_at >= strftime(now() - INTERVAL 1 DAY, '%%Y-%%m-%%d %%H:%%M:%%S')
) TO '%s' (
    FORMAT parquet,
    COMPRESSION zstd,
    PARTITION_BY (snapshot_date, region),
    OVERWRITE_OR_IGNORE true
);
SQL;

$query = sprintf($sql, DB_PATH, $snapshotDate, EXPORT_DIR);

$proc = proc_open(
    [DUCKDB_BIN, ':memory:'],
    [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
    $pipes
);

fwrite($pipes[0], $query);
fclose($pipes[0]);
$stderr = stream_get_contents($pipes[2]);
$exit = proc_close($proc);

if ($exit !== 0) {
    error_log('[export_parquet] FAILED: ' . trim($stderr));
    exit(1);
}

echo 'Exported snapshot ' . $snapshotDate . PHP_EOL;

A few deliberate choices in there:

  • READ_ONLY on the attach. DuckDB takes a read snapshot and never touches the WAL in write mode, so the running site is unaffected even if a fetch cycle overlaps.
  • All casting happens here. strptime turns SQLite's text timestamps into real TIMESTAMP columns once, at export time. Every downstream query gets clean types for free.
  • PARTITION_BY (snapshot_date, region) writes a hive layout: exports/snapshot_date=2026-06-12/region=DE/data_0.parquet. That layout is the whole reason later queries are fast — DuckDB prunes partitions from the file path before reading a single byte.
  • The WHERE clause makes the export incremental. Only rows touched in the last day are snapshotted, so the export takes about two seconds on shared hosting instead of rewriting history every night.

Getting the files off the server

Deploys for this site go out over FTP with lftp, so the analytics pull reuses the exact same mechanism in reverse. One line in a local cron:

lftp -e 'mirror --only-newer /data/exports ./exports; quit' -u $FTP_USER,$FTP_PASS $FTP_HOST

--only-newer means each morning I transfer one day's partitions — eight small Parquet files — not the whole archive. The hive directory structure survives the mirror untouched, which matters because DuckDB derives the snapshot_date and region columns from those directory names. After a few months the local exports/ tree holds the full history that the production database deliberately does not.

There is a nice asymmetry here: the production box only ever keeps the latest day or two of exports (a cleanup step deletes older partition directories), so the shared host stays lean while the laptop accumulates the long tail.

The queries that pay rent

With the data local, everything happens through read_parquet with hive_partitioning enabled. These three queries are the ones I actually run weekly, not demo material.

-- 1. Cross-region overlap: videos trending in 3+ regions on one day.
--    This is the core editorial signal for a multi-region discovery site.
SELECT
    video_id,
    any_value(title)            AS title,
    count(DISTINCT region)      AS region_count,
    list(DISTINCT region)       AS regions,
    max(view_count)             AS peak_views
FROM read_parquet('exports/**/*.parquet', hive_partitioning = true)
WHERE snapshot_date = DATE '2026-06-12'
GROUP BY video_id
HAVING count(DISTINCT region) >= 3
ORDER BY region_count DESC, peak_views DESC
LIMIT 25;

-- 2. Velocity: 24h view-count delta per video per region.
WITH daily AS (
    SELECT video_id, region, snapshot_date,
           max(view_count) AS views
    FROM read_parquet('exports/**/*.parquet', hive_partitioning = true)
    WHERE snapshot_date >= current_date - INTERVAL 7 DAY
    GROUP BY ALL
)
SELECT video_id, region, snapshot_date,
       views - lag(views) OVER w AS views_24h,
       round(100.0 * (views - lag(views) OVER w) / nullif(lag(views) OVER w, 0), 1) AS pct_24h
FROM daily
WINDOW w AS (PARTITION BY video_id, region ORDER BY snapshot_date)
QUALIFY views_24h IS NOT NULL
ORDER BY views_24h DESC
LIMIT 50;

-- 3. Category drift: week-over-week share of trending slots per category.
SELECT category_id,
       date_trunc('week', snapshot_date) AS week,
       count(*) AS slots,
       round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY date_trunc('week', snapshot_date)), 1) AS share_pct
FROM read_parquet('exports/**/*.parquet', hive_partitioning = true)
GROUP BY ALL
ORDER BY week DESC, share_pct DESC;

Things worth noticing if you are coming from SQLite:

  • QUALIFY filters on window-function results without an extra subquery layer. Query 2 in SQLite is a three-level nesting exercise; here it reads top to bottom.
  • GROUP BY ALL groups by every non-aggregated column. Small thing, huge quality-of-life for exploratory work where you keep adding columns.
  • list(DISTINCT region) returns an actual array. Seeing [US, GB, DE, IN] inline beats a second lookup query.
  • Partition pruning is visible. Query 1 filters on snapshot_date, which is a directory name, so DuckDB opens only that day's eight files. EXPLAIN confirms it. On a year of history, the query cost stays flat.

On my laptop, all three queries over ~90 days of snapshots (about 3.5 million rows) complete in well under a second. The same overlap query against the production SQLite schema — before I gave up on running it there — took around 30 seconds and pinned the CPU.

Automating the weekly digest in Python

Once the same four or five queries proved useful repeatedly, they graduated into a small Python script that runs after the morning FTP pull and writes a markdown digest. DuckDB's Python API makes this almost embarrassingly short:

#!/usr/bin/env python3
"""weekly_digest.py — cross-region trending summary from Parquet exports."""
import duckdb
from datetime import date, timedelta
from pathlib import Path

GLOB = 'exports/**/*.parquet'
since = date.today() - timedelta(days=7)

con = duckdb.connect()  # in-memory; the Parquet files ARE the database

overlap = con.execute("""
    SELECT video_id, any_value(title) AS title,
           count(DISTINCT region) AS regions,
           max(view_count) AS peak_views
    FROM read_parquet($glob, hive_partitioning = true)
    WHERE snapshot_date >= $since
    GROUP BY video_id
    HAVING count(DISTINCT region) >= 3
    ORDER BY regions DESC, peak_views DESC
    LIMIT 15
""", {'glob': GLOB, 'since': since}).fetchall()

by_region = con.execute("""
    SELECT region, count(DISTINCT video_id) AS uniques,
           round(avg(view_count)) AS avg_views
    FROM read_parquet($glob, hive_partitioning = true)
    WHERE snapshot_date >= $since
    GROUP BY region ORDER BY uniques DESC
""", {'glob': GLOB, 'since': since}).fetchall()

lines = [f'# Trending digest — week of {since}', '', '## Multi-region hits', '']
for vid, title, regions, views in overlap:
    lines.append(f'- **{title}** — {regions} regions, peak {views:,} views (`{vid}`)')

lines += ['', '## Region health', '']
for region, uniques, avg_views in by_region:
    lines.append(f'- {region}: {uniques} unique trending videos, avg {int(avg_views):,} views')

Path(f'digests/{since}.md').write_text('\n'.join(lines) + '\n')
print(f'wrote digests/{since}.md ({len(overlap)} multi-region hits)')

Note the parameterized queries — $glob and $since — which keep the SQL copy-pasteable between the CLI and the script. The digest tells me in thirty seconds whether a region's API fetch silently degraded (uniques drop), whether a category is surging, and what the genuinely global videos were this week. The third one feeds directly back into the product.

Closing the loop back to production

The analytics layer would be a vanity project if nothing flowed back to the site. The piece that does: the multi-region overlap query gets serialized to a small JSON file and pushed to each host over the existing FTP deploy channel. On the PHP side, rendering it is deliberately dumb — no DuckDB, no SQLite involvement, just a cached file read:

<?php
declare(strict_types=1);

final readonly class GlobalTrending
{
    public function __construct(private string $path) {}

    /** @return list<array{video_id: string, title: string, regions: int}> */
    public function top(int $limit = 10): array
    {
        if (!is_file($this->path) || filemtime($this->path) < time() - 86400 * 2) {
            return []; // stale or missing → widget simply doesn't render
        }
        $rows = json_decode(file_get_contents($this->path), true, flags: JSON_THROW_ON_ERROR);
        return array_slice($rows, 0, $limit);
    }
}

The staleness check is the important line. If my laptop is closed for a week and no fresh JSON arrives, the "trending worldwide" widget disappears instead of showing fossilized data. The production site never depends on the analytics pipeline being alive — it only gets enhanced by it. For a one-person operation, that failure mode is the difference between a hobby-friendly system and a pager.

Gotchas I hit so you don't have to

  • SQLite type affinity will bite exactly once. A handful of my early view_count values were written as text by an old fetcher version. DuckDB's sqlite extension surfaced them as VARCHAR and the first export died on the cast. Fix it at export time with TRY_CAST and a logged count of nulls, not by mutating production data mid-flight.
  • Normalize timezones before comparing regions. Eight regions means fetch timestamps that look misaligned if any layer applied a local offset. I store everything UTC in SQLite and declared the export's strptime results as UTC; do this on day one, because retrofitting timezone hygiene across months of Parquet partitions is miserable.
  • OVERWRITE_OR_IGNORE matters for reruns. Without it, re-running the export after a partial failure either errors or duplicates rows inside a partition. With it, the day's partition is rewritten atomically and the cron is safely idempotent.
  • Don't glob more than you need. read_parquet('exports/**/*.parquet') is fine with a snapshot_date filter (pruning saves you), but an unfiltered SELECT count(*) over a year of partitions opens every file. Listing thousands of small files has fixed overhead even when each one is tiny — keep daily partitions per region as single files, resist the urge to export hourly.
  • Pin the DuckDB version in both places. The CLI binary on the host writes the Parquet; the Python package on the laptop reads it. Parquet itself is stable, but I keep both on the same release anyway so behavior differences in strptime and casting never become a debugging session.
  • Dedup within snapshots, not across them. The same video_id legitimately appears in multiple regions and on multiple days — that's the signal, not noise. Every aggregate needs to be explicit about which axis it collapses, hence the count(DISTINCT region) and max(view_count) patterns above rather than naive counts.

Conclusion

The pattern here is small and reusable: keep the transactional store (SQLite) doing exactly what it is good at, snapshot the analytical slice to hive-partitioned Parquet on a schedule, move files with whatever boring transport you already trust — FTP, in my case — and let DuckDB do the heavy reading on hardware you don't share with your visitors. No warehouse, no streaming infra, no monthly bill. For a multi-region discovery site fed by cron jobs, it answers real product questions in seconds, and the production box never even notices the questions were asked.

If you are running anything on SQLite and finding yourself writing painful analytical queries against it, try the ATTACH ... (TYPE sqlite) one-liner first. Five minutes with that is usually all it takes to be convinced.