Skip to content
Draft
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
15 changes: 15 additions & 0 deletions docs/guides/working_with_data/sql.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,21 @@ Lint your SQL code and provide better autocompletions and error highlighting.

To disable the linter, you can set the `sql_linter` configuration to `false` in your `pyproject.toml` file or disable it in the marimo editor's settings menu.

**SQL keyword case**

marimo generates SQL in a few places β€” new SQL cell boilerplate, keyword
autocompletions, and table snippets in the Data Sources panel. By default,
generated keywords are uppercase (`SELECT`, `FROM`). To generate lowercase
keywords instead, set `sql_keyword_case` in your `pyproject.toml`:

```toml
[tool.marimo.runtime]
sql_keyword_case = "lower"
```

or change it in the marimo editor's settings menu. This only affects SQL that
marimo generates; formatting a cell preserves the case of SQL you have written.

**SQL Formatting**

Click on the paint roller icon at the bottom right of the SQL cell to format your SQL code.
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/app-config/common.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* Copyright 2026 Marimo. All rights reserved. */

import type { HTMLProps, PropsWithChildren } from "react";
import type { SqlOutputType } from "@/core/config/config-schema";
import type {
SqlKeywordCase,
SqlOutputType,
} from "@/core/config/config-schema";
import { cn } from "@/utils/cn";

export const formItemClasses = "flex flex-row items-center space-x-1 space-y-0";
Expand Down Expand Up @@ -60,3 +63,11 @@ export const SQL_OUTPUT_SELECT_OPTIONS: {
{ label: "Lazy Polars", value: "lazy-polars" },
{ label: "Pandas", value: "pandas" },
];

export const SQL_KEYWORD_CASE_SELECT_OPTIONS: {
label: string;
value: SqlKeywordCase;
}[] = [
{ label: "Uppercase (Default)", value: "upper" },
{ label: "Lowercase", value: "lower" },
];
35 changes: 35 additions & 0 deletions frontend/src/components/app-config/data-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Checkbox } from "../ui/checkbox";
import {
formItemClasses,
SettingGroup,
SQL_KEYWORD_CASE_SELECT_OPTIONS,
SQL_OUTPUT_SELECT_OPTIONS,
} from "./common";
import { IsOverridden, OverriddenFormField } from "./is-overridden";
Expand Down Expand Up @@ -266,6 +267,40 @@ export const DataForm = ({
</div>
)}
/>

<OverriddenFormField
control={form.control}
name="runtime.sql_keyword_case"
render={({ field, override }) => (
<div className="flex flex-col space-y-1">
<FormItem className={formItemClasses}>
<FormLabel>SQL keyword case</FormLabel>
<FormControl>
<NativeSelect
data-testid="user-config-sql-keyword-case-select"
onChange={(e) => field.onChange(e.target.value)}
value={override.value ?? "upper"}
disabled={field.disabled || override.isOverridden}
className="inline-flex mr-2"
>
{SQL_KEYWORD_CASE_SELECT_OPTIONS.map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</NativeSelect>
</FormControl>
<FormMessage />
<IsOverridden override={override} />
</FormItem>

<FormDescription>
The keyword case used in generated SQL, such as cell
boilerplate, autocomplete suggestions, and table snippets.
</FormDescription>
</div>
)}
/>
</SettingGroup>
</>
);
Expand Down
86 changes: 85 additions & 1 deletion frontend/src/components/datasources/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { userConfigAtom } from "@/core/config/config";
import { defaultUserConfig } from "@/core/config/config-schema";
import type { SQLTableContext } from "@/core/datasets/data-source-connections";
import { DUCKDB_ENGINE } from "@/core/datasets/engines";
import { store } from "@/core/state/jotai";
import type {
Database,
DatabaseSchema,
Expand Down Expand Up @@ -467,6 +470,87 @@ describe("sqlCode", () => {
);
});
});

describe("lowercase keyword case", () => {
beforeEach(() => {
const config = defaultUserConfig();
store.set(userConfigAtom, {
...config,
runtime: { ...config.runtime, sql_keyword_case: "lower" },
});
});

afterEach(() => {
store.set(userConfigAtom, defaultUserConfig());
});

it("should lowercase keywords in the default formatter", () => {
const sqlTableContext: SQLTableContext = {
engine: "snowflake",
schema: "public",
defaultSchema: "public",
defaultDatabase: "mydb",
database: "mydb",
dialect: "snowflake",
};

const result = sqlCode({
table: mockTable,
columnName: mockColumn.name,
sqlTableContext,
});
expect(result).toBe(
'_df = mo.sql(f"""\nselect email from users limit 100\n""", engine=snowflake)',
);
});

it("should lowercase SELECT TOP for MSSQL", () => {
const sqlTableContext: SQLTableContext = {
engine: "mssql",
schema: "dbo",
defaultSchema: "dbo",
defaultDatabase: "master",
database: "master",
dialect: "mssql",
};

const result = sqlCode({
table: mockTable,
columnName: mockColumn.name,
sqlTableContext,
});
expect(result).toBe(
'_df = mo.sql(f"""\nselect top 100 email from users\n""", engine=mssql)',
);
});

it("should lowercase keywords but not quoted identifiers for postgres", () => {
const sqlTableContext: SQLTableContext = {
engine: "postgres",
schema: "public",
defaultSchema: "public",
defaultDatabase: "mydb",
database: "mydb",
dialect: "postgres",
};

const result = sqlCode({
table: mockTable,
columnName: mockColumn.name,
sqlTableContext,
});
expect(result).toBe(
'_df = mo.sql(f"""\nselect "email" from "users" limit 100\n""", engine=postgres)',
);
});

it("should lowercase keywords without sqlTableContext", () => {
const result = sqlCode({ table: mockTable, columnName: mockColumn.name });
expect(result).toBe(
"_df = mo.sql(f'select \"email\" from users limit 100')",
);
});
});
});

describe("tableUniqueId", () => {
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/datasources/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { BigQueryDialect } from "@marimo-team/codemirror-sql/dialects";
import { sqlKeyword } from "@/core/codemirror/language/languages/sql/keyword-case";
import { isKnownDialect } from "@/core/codemirror/language/languages/sql/utils";
import type { SQLTableContext } from "@/core/datasets/data-source-connections";
import { DUCKDB_ENGINE } from "@/core/datasets/engines";
Expand Down Expand Up @@ -111,7 +112,7 @@ interface SqlCodeFormatter {
const defaultFormatter: SqlCodeFormatter = {
formatTablePath: (tablePath: string[]) => tablePath.join("."),
formatSelectClause: (columnName: string, tableName: string) =>
`SELECT ${columnName} FROM ${tableName} LIMIT 100`,
`${sqlKeyword("SELECT")} ${columnName} ${sqlKeyword("FROM")} ${tableName} ${sqlKeyword("LIMIT")} 100`,
};

function getFormatter(dialect: string): SqlCodeFormatter {
Expand All @@ -136,7 +137,7 @@ function getFormatter(dialect: string): SqlCodeFormatter {
return {
formatTablePath: defaultFormatter.formatTablePath,
formatSelectClause: (columnName: string, tableName: string) =>
`SELECT TOP 100 ${columnName} FROM ${tableName}`,
`${sqlKeyword("SELECT TOP")} 100 ${columnName} ${sqlKeyword("FROM")} ${tableName}`,
};
case "timescaledb":
case "postgres":
Expand All @@ -148,7 +149,7 @@ function getFormatter(dialect: string): SqlCodeFormatter {
formatTablePath: (tablePath: string[]) =>
tablePath.map((part) => `"${part}"`).join("."),
formatSelectClause: (columnName: string, tableName: string) =>
`SELECT ${columnName === "*" ? "*" : `"${columnName}"`} FROM ${tableName} LIMIT 100`,
`${sqlKeyword("SELECT")} ${columnName === "*" ? "*" : `"${columnName}"`} ${sqlKeyword("FROM")} ${tableName} ${sqlKeyword("LIMIT")} 100`,
};
case "db2":
case "db2i":
Expand Down Expand Up @@ -231,7 +232,7 @@ export function sqlCode({
return `_df = mo.sql(f"""\n${selectClause}\n""", engine=${engine})`;
}

return `_df = mo.sql(f'SELECT "${columnName}" FROM ${table.name} LIMIT 100')`;
return `_df = mo.sql(f'${sqlKeyword("SELECT")} "${columnName}" ${sqlKeyword("FROM")} ${table.name} ${sqlKeyword("LIMIT")} 100')`;
}

export function convertStatsName(stat: ColumnHeaderStatsKey, type: DataType) {
Expand Down
85 changes: 81 additions & 4 deletions frontend/src/core/codemirror/language/__tests__/sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import { forEachDiagnostic, forceLinting } from "@codemirror/lint";
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { DuckDBDialect } from "@marimo-team/codemirror-sql/dialects";
import { SQLParser } from "@marimo-team/smart-cells";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CellId } from "@/core/cells/ids";
import type {
CompletionConfig,
DiagnosticsConfig,
LSPConfig,
import { userConfigAtom } from "@/core/config/config";
import {
type CompletionConfig,
defaultUserConfig,
type DiagnosticsConfig,
type LSPConfig,
type SqlKeywordCase,
} from "@/core/config/config-schema";
import type { DataSourceConnection } from "@/core/datasets/data-source-connections";
import {
Expand Down Expand Up @@ -634,6 +638,40 @@ _df = mo.sql(
setLatestEngineSelected(DUCKDB_ENGINE);
expect(adapter.defaultCode).toBe(`_df = mo.sql(f"""SELECT * FROM """)`);
});

it("should match SQLParser.defaultCode under the default config", () => {
setLatestEngineSelected(DUCKDB_ENGINE);
expect(adapter.defaultCode).toBe(new SQLParser().defaultCode);
});

describe("with lowercase keyword case", () => {
const setKeywordCase = (keywordCase: SqlKeywordCase) => {
const config = defaultUserConfig();
store.set(userConfigAtom, {
...config,
runtime: { ...config.runtime, sql_keyword_case: keywordCase },
});
};

afterEach(() => {
store.set(userConfigAtom, defaultUserConfig());
});

it("should lowercase keywords in defaultCode with an engine", () => {
setKeywordCase("lower");
const engine = "postgres_engine" as ConnectionName;
setLatestEngineSelected(engine);
expect(adapter.defaultCode).toBe(
`_df = mo.sql(f"""select * from """, engine=${engine})`,
);
});

it("should lowercase keywords in defaultCode without an engine", () => {
setKeywordCase("lower");
setLatestEngineSelected(DUCKDB_ENGINE);
expect(adapter.defaultCode).toBe(`_df = mo.sql(f"""select * from """)`);
});
});
});
});

Expand Down Expand Up @@ -2435,6 +2473,45 @@ describe("tablesCompletionSource", () => {
);
});

it("should provide lowercase keyword completions when configured", async () => {
const config = defaultUserConfig();
store.set(userConfigAtom, {
...config,
runtime: { ...config.runtime, sql_keyword_case: "lower" },
});

const mockConnection: DataSourceConnection = {
name: TEST_ENGINE,
dialect: "postgres",
display_name: "postgres",
source: "postgres",
databases: [],
};

store.set(dataSourceConnectionsAtom, {
connectionsMap: new Map([[TEST_ENGINE, mockConnection]]),
latestEngineSelected: TEST_ENGINE,
});

const state = createEditorState("sel", {
engine: TEST_ENGINE,
});
const ctx = createCompletionContext(state, 3);

const adapter = new SQLLanguageAdapter();
const extensions = adapter.getExtension(...TEST_EXTENSION_ARGS);
const completion = getCompletionSources(extensions)?.[2];

expect(completion).toBeDefined();
const result = await completion!(ctx);
expect(result).toBeDefined();
expect(result?.options.some((opt) => opt.label === "select")).toBe(
true,
);

store.set(userConfigAtom, defaultUserConfig());
});

it("should not provide keyword completions after dot", async () => {
const mockConnection: DataSourceConnection = {
name: TEST_ENGINE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { afterEach, describe, expect, it } from "vitest";
import { userConfigAtom } from "@/core/config/config";
import {
defaultUserConfig,
type SqlKeywordCase,
} from "@/core/config/config-schema";
import { store } from "@/core/state/jotai";
import { sqlKeyword, sqlKeywordCase } from "../keyword-case";

function setKeywordCase(keywordCase: SqlKeywordCase | undefined) {
const config = defaultUserConfig();
store.set(userConfigAtom, {
...config,
runtime: { ...config.runtime, sql_keyword_case: keywordCase },
});
}

describe("sqlKeywordCase", () => {
afterEach(() => {
store.set(userConfigAtom, defaultUserConfig());
});

it("defaults to upper", () => {
store.set(userConfigAtom, defaultUserConfig());
expect(sqlKeywordCase()).toBe("upper");
expect(sqlKeyword("select")).toBe("SELECT");
});

it("returns lower when configured", () => {
setKeywordCase("lower");
expect(sqlKeywordCase()).toBe("lower");
expect(sqlKeyword("SELECT TOP")).toBe("select top");
});

it("falls back to upper when the setting is missing", () => {
setKeywordCase(undefined);
expect(sqlKeywordCase()).toBe("upper");
expect(sqlKeyword("limit")).toBe("LIMIT");
});
});
Loading
Loading