Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ npm-debug.log*
pnpm-debug.log*
jaffle-shop/
!packages/create-dql-app/templates/jaffle-shop/
.claude/worktrees/
.claude/
*.dqlnb.run.json
*.dql.run.json
11 changes: 6 additions & 5 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@duckcodeailabs/dql-cli",
"version": "1.0.3",
"version": "1.0.4",
"description": "Public CLI for parsing, formatting, testing, and certifying DQL blocks",
"license": "Apache-2.0",
"type": "module",
Expand All @@ -27,17 +27,18 @@
"directory": "apps/cli"
},
"dependencies": {
"@duckcodeailabs/dql-compiler": "workspace:*",
"@duckcodeailabs/dql-connectors": "workspace:*",
"@duckcodeailabs/dql-core": "workspace:*",
"@duckcodeailabs/dql-compiler": "workspace:*",
"@duckcodeailabs/dql-project": "workspace:*",
"@duckcodeailabs/dql-governance": "workspace:*",
"@duckcodeailabs/dql-notebook": "workspace:*"
"@duckcodeailabs/dql-notebook": "workspace:*",
"@duckcodeailabs/dql-project": "workspace:*",
"isomorphic-git": "^1.27.0"
},
"devDependencies": {
"@duckcodeailabs/dql-notebook-app": "workspace:*",
"typescript": "^5.7.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
Expand Down
51 changes: 42 additions & 9 deletions apps/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { readFileSync } from 'node:fs';
import { diffDQL, renderDiffText } from '@duckcodeailabs/dql-core';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { diffDQL, diffNotebook, renderDiffText, type DiffReport } from '@duckcodeailabs/dql-core';
import { findRepoContext, readHeadBlob } from '../git-service.js';
import type { CLIFlags } from '../args.js';

export async function runDiff(
beforePath: string | null,
firstArg: string | null,
rest: string[],
flags: CLIFlags,
): Promise<void> {
const afterPath = rest[0];
if (!beforePath || !afterPath) {
console.error('Usage: dql diff <before.dql> <after.dql>');
const secondArg = rest[0];

if (!firstArg) {
console.error('Usage: dql diff <path> | dql diff <before> <after>');
process.exit(1);
}

const before = readFileSync(beforePath, 'utf-8');
const after = readFileSync(afterPath, 'utf-8');
const report = diffDQL(before, after);
const report = secondArg
? await diffTwoFiles(firstArg, secondArg)
: await diffAgainstHead(firstArg);

if (flags.format === 'json') {
console.log(JSON.stringify(report, null, 2));
Expand All @@ -27,3 +30,33 @@ export async function runDiff(
// (`dql diff a b && echo unchanged`), mirroring git-diff and fmt --check.
if (!report.identical) process.exit(1);
}

async function diffTwoFiles(beforePath: string, afterPath: string): Promise<DiffReport> {
const before = readFileSync(beforePath, 'utf-8');
const after = readFileSync(afterPath, 'utf-8');
return diffByExtension(afterPath, before, after);
}

async function diffAgainstHead(path: string): Promise<DiffReport> {
const absPath = resolve(path);
if (!existsSync(absPath)) {
console.error(`Error: file not found: ${path}`);
process.exit(1);
}
const ctx = findRepoContext(absPath);
if (!ctx) {
console.error(`Error: ${path} is not inside a git repository. Use: dql diff <before> <after>`);
process.exit(1);
}
const before = await readHeadBlob(ctx);
const after = readFileSync(absPath, 'utf-8');
return diffByExtension(absPath, before, after);
}

function diffByExtension(path: string, before: string | null, after: string): DiffReport {
if (path.endsWith('.dqlnb')) {
return diffNotebook(before, after);
}
// diffDQL doesn't accept null — newly added files diff against empty source.
return diffDQL(before ?? '', after);
}
6 changes: 4 additions & 2 deletions apps/cli/src/commands/fmt.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { canonicalize } from '@duckcodeailabs/dql-core';
import { canonicalize, canonicalizeNotebook } from '@duckcodeailabs/dql-core';
import type { CLIFlags } from '../args.js';

export async function runFmt(filePath: string, flags: CLIFlags): Promise<void> {
const source = readFileSync(filePath, 'utf-8');
const formatted = canonicalize(source);
const formatted = filePath.endsWith('.dqlnb')
? canonicalizeNotebook(source)
: canonicalize(source);
const changed = source !== formatted;

if (flags.check) {
Expand Down
90 changes: 90 additions & 0 deletions apps/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { canonicalize, canonicalizeNotebook } from '@duckcodeailabs/dql-core';
import type { CLIFlags } from '../args.js';

export type MigrationSource = 'looker' | 'tableau' | 'dbt' | 'metabase' | 'raw-sql';
Expand Down Expand Up @@ -48,13 +51,18 @@ function generateBlockDQL(opts: {
}

export async function runMigrate(file: string, flags: CLIFlags): Promise<void> {
if (file === 'format') {
await runFormatMigrate(flags.input || '.', flags);
return;
}
// file is used as the source type for migration
const source = file as MigrationSource;
const validSources: MigrationSource[] = ['looker', 'tableau', 'dbt', 'metabase', 'raw-sql'];

if (!validSources.includes(source)) {
console.error(`\n ✗ Unknown migration source: "${source}"`);
console.error(` Valid sources: ${validSources.join(', ')}`);
console.error(` Or: "format" to upgrade .dql/.dqlnb files to the canonical on-disk format`);
console.error('');
process.exit(1);
}
Expand Down Expand Up @@ -130,3 +138,85 @@ export async function runMigrate(file: string, flags: CLIFlags): Promise<void> {
console.log(' 4. Commit and push for certification');
console.log('');
}

interface FormatMigrateReport {
scanned: number;
alreadyCanonical: number;
upgraded: number;
failed: Array<{ path: string; error: string }>;
dryRun: boolean;
}

export async function runFormatMigrate(root: string, flags: CLIFlags): Promise<void> {
const dryRun = flags.check === true;
const report: FormatMigrateReport = {
scanned: 0,
alreadyCanonical: 0,
upgraded: 0,
failed: [],
dryRun,
};

for (const absPath of walkDqlFiles(root)) {
report.scanned += 1;
const rel = relative(root, absPath) || absPath;
const source = readFileSync(absPath, 'utf-8');
let canonical: string;
try {
canonical = absPath.endsWith('.dqlnb') ? canonicalizeNotebook(source) : canonicalize(source);
} catch (error) {
report.failed.push({ path: rel, error: error instanceof Error ? error.message : String(error) });
continue;
}
if (canonical === source) {
report.alreadyCanonical += 1;
continue;
}
if (!dryRun) writeFileSync(absPath, canonical, 'utf-8');
report.upgraded += 1;
}

if (flags.format === 'json') {
console.log(JSON.stringify(report, null, 2));
if (report.failed.length > 0) process.exit(1);
return;
}

console.log(`\n DQL format migration${dryRun ? ' (dry run)' : ''}`);
console.log(' ─────────────────────────────');
console.log(` Scanned: ${report.scanned}`);
console.log(` Already canonical: ${report.alreadyCanonical}`);
console.log(` ${dryRun ? 'Would upgrade' : 'Upgraded'}: ${report.upgraded}`);
if (report.failed.length > 0) {
console.log(` Failed: ${report.failed.length}`);
for (const f of report.failed) console.log(` ✗ ${f.path}: ${f.error}`);
process.exit(1);
}
console.log('');
}

function* walkDqlFiles(root: string): Generator<string> {
const stack: string[] = [root];
while (stack.length > 0) {
const dir = stack.pop()!;
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'target') continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile() && (entry.name.endsWith('.dql') || entry.name.endsWith('.dqlnb'))) {
try {
if (statSync(full).size > 0) yield full;
} catch {
// skip unreadable
}
}
}
}
}
60 changes: 60 additions & 0 deletions apps/cli/src/git-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Thin isomorphic-git wrapper used by `dql diff <path>` to resolve the
// HEAD blob for a file inside a git repo.

import { statSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import git from 'isomorphic-git';
import * as fs from 'node:fs';

export interface RepoContext {
/** Absolute path to the repository root (where `.git` lives). */
dir: string;
/** Relative path of the requested file from the repo root, POSIX-separated. */
relpath: string;
}

/**
* Locate the git repository containing `absPath`. Walks upward looking
* for a `.git` directory. Returns `null` when no repository is found.
*/
export function findRepoContext(absPath: string): RepoContext | null {
let dir = resolve(absPath);
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
dir = resolve(dir, '..');
}
while (true) {
if (statSync(join(dir, '.git'), { throwIfNoEntry: false })) {
const rel = relative(dir, absPath).split(/[\\/]/).join('/');
return { dir, relpath: rel };
}
const parent = resolve(dir, '..');
if (parent === dir) return null;
dir = parent;
}
}

/**
* Read the HEAD-committed contents of `relpath` as a UTF-8 string. Returns
* `null` if HEAD has no such file (newly added in the working copy).
*/
export async function readHeadBlob(ctx: RepoContext): Promise<string | null> {
try {
const commitSha = await git.resolveRef({ fs, dir: ctx.dir, ref: 'HEAD' });
const { blob } = await git.readBlob({
fs,
dir: ctx.dir,
oid: commitSha,
filepath: ctx.relpath,
});
return new TextDecoder('utf-8').decode(blob);
} catch (error) {
if (isNotFoundError(error)) return null;
throw error;
}
}

function isNotFoundError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const code = (error as { code?: string }).code;
return code === 'NotFoundError' || code === 'ENOENT';
}
6 changes: 4 additions & 2 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ const HELP = `
dql certify <file.dql> Evaluate certification rules
dql info <file.dql> Show block metadata
dql migrate <source> Scaffold migration from looker/tableau/dbt/metabase/raw-sql
dql fmt <file.dql> Format DQL file in place
dql diff <before> <after> Semantic diff between two .dql files
dql migrate format [--check] Upgrade all .dql/.dqlnb files to canonical format
dql fmt <file.dql|.dqlnb> Format DQL/notebook file in place
dql diff <path> Diff a .dql/.dqlnb file vs HEAD
dql diff <before> <after> Semantic diff between two files
dql notebook [path] Launch the browser-first notebook for a project
dql semantic <sub> [path] Semantic layer: list, validate, query, pull
dql compile [path] Generate project manifest (dql-manifest.json)
Expand Down
Loading
Loading