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
1 change: 1 addition & 0 deletions changelog.d/h2-quote-identifiers.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- H2 now quotes table and column names in the datasource's own fold case (upper by default, lower with `DATABASE_TO_LOWER=TRUE`), so reserved-word columns such as `order` and `group` work in queries and `order=` clauses. Datasources with case folding disabled (`DATABASE_TO_UPPER=FALSE`) keep today's unquoted SQL unchanged (#4440)
129 changes: 129 additions & 0 deletions vendor/wheels/databaseAdapters/H2/H2Model.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,135 @@ component extends="wheels.databaseAdapters.Base" output=false {
return true;
}

/**
* The case this H2 datasource folds unquoted identifiers to: `upper` (H2's
* default), `lower` (`DATABASE_TO_LOWER=TRUE`) or `none` (case folding
* disabled with `DATABASE_TO_UPPER=FALSE`, no datasource, or an unreadable
* settings table). Probed once per datasource per application lifetime and
* cached in the application's Wheels settings beside `schemaColumnCache`, so
* a tenant datasource gets its own value, a reload re-probes, and the probe
* never runs per identifier. A failed probe degrades to `none`, which is the
* unquoted SQL H2 received before #4440.
*/
public string function $identifierCase() {
if (!StructKeyExists(variables, "dataSource") || !Len(variables.dataSource)) {
// A bare adapter (unit tests) has no datasource: leave identifiers alone.
return "none";
}
local.dataSource = $effectiveDataSource();
local.appKey = $appKey();
try {
if (
StructKeyExists(application, local.appKey)
&& StructKeyExists(application[local.appKey], "h2IdentifierCase")
&& StructKeyExists(application[local.appKey].h2IdentifierCase, local.dataSource)
) {
return application[local.appKey].h2IdentifierCase[local.dataSource];
}
} catch (any e) {
// fall through to a fresh probe
}
local.identifierCase = $readIdentifierCase(dataSource = local.dataSource);
try {
if (StructKeyExists(application, local.appKey)) {
lock name="wheels.h2IdentifierCase" type="exclusive" timeout="10" {
if (!StructKeyExists(application[local.appKey], "h2IdentifierCase")) {
application[local.appKey].h2IdentifierCase = {};
}
application[local.appKey].h2IdentifierCase[local.dataSource] = local.identifierCase;
}
}
} catch (any e) {
// Caching is best-effort; the probed value is still correct for this call.
}
return local.identifierCase;
}

/**
* Internal function. Reads H2's identifier folding settings. H2 2.x exposes
* `SETTING_NAME` / `SETTING_VALUE`; H2 1.3.x exposes `NAME` / `VALUE`, and
* has no `DATABASE_TO_LOWER` row at all. `NON_KEYWORDS` is read for the log
* line only — it never changes the classification.
*/
public string function $readIdentifierCase(required string dataSource) {
local.options = {datasource = arguments.dataSource};
if (StructKeyExists(variables, "username") && Len(variables.username)) {
local.options.username = variables.username;
}
if (StructKeyExists(variables, "password") && Len(variables.password)) {
local.options.password = variables.password;
}

local.settings = {};
local.rows = "";
try {
local.rows = QueryExecute(
"SELECT SETTING_NAME, SETTING_VALUE FROM INFORMATION_SCHEMA.SETTINGS WHERE SETTING_NAME IN ('DATABASE_TO_UPPER', 'DATABASE_TO_LOWER', 'NON_KEYWORDS')",
[],
local.options
);
} catch (any e) {
try {
local.rows = QueryExecute(
"SELECT NAME, VALUE FROM INFORMATION_SCHEMA.SETTINGS WHERE NAME IN ('DATABASE_TO_UPPER', 'DATABASE_TO_LOWER', 'NON_KEYWORDS')",
[],
local.options
);
} catch (any e2) {
writeLog(
type = "warning",
file = "wheels_h2_identifier_case",
text = "Could not read identifier folding settings for H2 datasource `#arguments.dataSource#`; identifiers will not be quoted. #e2.message#"
);
return "none";
}
}

local.nameColumn = ListFindNoCase(local.rows.columnList, "SETTING_NAME") ? "SETTING_NAME" : "NAME";
local.valueColumn = ListFindNoCase(local.rows.columnList, "SETTING_VALUE") ? "SETTING_VALUE" : "VALUE";
for (local.i = 1; local.i <= local.rows.recordCount; local.i++) {
local.settings[UCase(Trim(local.rows[local.nameColumn][local.i]))] = UCase(Trim(local.rows[local.valueColumn][local.i]));
}

if (!StructKeyExists(local.settings, "DATABASE_TO_LOWER")) {
// H2 1.3.x: no DATABASE_TO_LOWER setting, and quoted identifiers there are
// case-insensitive, so the default upper folding is always safe.
local.identifierCase = "upper";
} else if (local.settings.DATABASE_TO_LOWER == "TRUE") {
local.identifierCase = "lower";
} else if (StructKeyExists(local.settings, "DATABASE_TO_UPPER") && local.settings.DATABASE_TO_UPPER == "FALSE") {
local.identifierCase = "none";
} else {
local.identifierCase = "upper";
}

local.nonKeywords = StructKeyExists(local.settings, "NON_KEYWORDS") ? local.settings.NON_KEYWORDS : "";
writeLog(
type = "information",
file = "wheels_h2_identifier_case",
text = "H2 datasource `#arguments.dataSource#` folds identifiers to #local.identifierCase#; NON_KEYWORDS=#local.nonKeywords#"
);
return local.identifierCase;
}

/**
* Quote a table or column name when H2 folds it, so reserved words such as
* `order` and `group` are usable. The name is quoted in the datasource's own
* fold case and embedded double quotes are doubled. With folding off the name
* is returned unchanged — byte-for-byte the unquoted SQL H2 got before #4440,
* which also keeps duplicate-by-case schemas (only possible with folding off)
* unambiguous. Aliases are never quoted: table aliases stay bare and the
* select-list aliases read back by $lowerCaseColumnNames() are unchanged.
*/
public string function $quoteIdentifier(required string name) {
local.identifierCase = $identifierCase();
if (local.identifierCase == "none") {
return arguments.name;
}
local.folded = (local.identifierCase == "upper") ? UCase(arguments.name) : LCase(arguments.name);
return """" & Replace(local.folded, """", """""", "all") & """";
}

/**
* H2 1.3.172 (the driver Lucee bundles) returns rows in the wrong order once
* LIMIT + OFFSET exceeds the 32-bit maximum (#3684), and the query builder's
Expand Down
15 changes: 15 additions & 0 deletions vendor/wheels/tests/_assets/adapters/H2CaseProbe.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* An H2 model adapter whose identifier fold case is fixed by the spec, so the
* quoting rule for "upper" / "lower" / "none" is unit-testable without a live
* H2 datasource. Overrides $identifierCase() only; $quoteIdentifier() is the
* production rule under test.
*/
component extends="wheels.databaseAdapters.H2.H2Model" output=false {

this.identifierCase = "upper";

public string function $identifierCase() {
return this.identifierCase;
}

}
28 changes: 28 additions & 0 deletions vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,34 @@ component extends="wheels.WheelsTest" {
expect(mssql.$quoteIdentifier(name = "a]]b")).toBe("[a]]]]b]");
});

it("H2 $quoteIdentifier quotes in the datasource's own fold case (##4440)", () => {
var upper = CreateObject("component", "wheels.tests._assets.adapters.H2CaseProbe");
upper.identifierCase = "upper";
expect(upper.$quoteIdentifier(name = "users")).toBe('"USERS"');
expect(upper.$quoteIdentifier(name = "order")).toBe('"ORDER"');
expect(upper.$quoteIdentifier(name = 'we"ird')).toBe('"WE""IRD"');
expect(upper.$quoteIdentifier(name = 'a""b')).toBe('"A""""B"');

var lower = CreateObject("component", "wheels.tests._assets.adapters.H2CaseProbe");
lower.identifierCase = "lower";
expect(lower.$quoteIdentifier(name = "USERS")).toBe('"users"');
expect(lower.$quoteIdentifier(name = "ORDER")).toBe('"order"');
expect(lower.$quoteIdentifier(name = 'We"ird')).toBe('"we""ird"');
expect(lower.$quoteIdentifier(name = 'a""b')).toBe('"a""""b"');

var none = CreateObject("component", "wheels.tests._assets.adapters.H2CaseProbe");
none.identifierCase = "none";
expect(none.$quoteIdentifier(name = "users")).toBe("users");
expect(none.$quoteIdentifier(name = "order")).toBe("order");
expect(none.$quoteIdentifier(name = 'we"ird')).toBe('we"ird');
});

it("H2 without a datasource reports no folding and leaves identifiers unquoted", () => {
var h2 = CreateObject("component", "wheels.databaseAdapters.H2.H2Model");
expect(h2.$identifierCase()).toBe("none");
expect(h2.$quoteIdentifier(name = "users")).toBe("users");
});

it("migrator quoteTableName/quoteColumnName double the closing delimiter, per adapter", () => {
// lower-case inputs, so the output does not depend on migratorObjectCase
var sqlite = CreateObject("component", "wheels.databaseAdapters.SQLite.SQLiteMigrator");
Expand Down
99 changes: 82 additions & 17 deletions vendor/wheels/tests/specs/model/OrderReservedWordSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,16 @@
* Ordering by a column whose name is a reserved word, through order="table.column"
* as well as order="column" (4374): the qualified form is quoted like the bare one.
* The spec owns its table, c_o_r_e_reservedorders, created with the adapter's own
* identifier quoting, and drops it afterwards. Skipped on H2, whose adapter doesn't
* quote identifiers.
* identifier quoting, and drops it afterwards. As of #4440 the H2 adapter quotes
* identifiers in the datasource's own fold case, so this runs unskipped there too;
* the second describe pins that probe and the default-folding behaviour on H2.
*/
component extends="wheels.WheelsTest" {

function beforeAll() {
variables.ds = application.wheels.dataSourceName;
variables.table = "c_o_r_e_reservedorders";
// The H2 adapter doesn't quote identifiers ($quoteIdentifier returns the name as is),
// so a table with reserved-word columns can't be created or ordered by there.
variables.isH2 = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "H2";
if (variables.isH2) {
return;
}
variables.dialect = application.wo.model("author").$dialectName();
$dropTable();
QueryExecute(
"CREATE TABLE #variables.table# (id INT NOT NULL PRIMARY KEY, #$q('order')# INT NOT NULL, #$q('group')# VARCHAR(20) NOT NULL)",
Expand All @@ -33,9 +29,7 @@ component extends="wheels.WheelsTest" {
}

function afterAll() {
if (!variables.isH2) {
$dropTable();
}
$dropTable();
}

// An identifier quoted by the model adapter ("x", `x` or [x], per database).
Expand All @@ -51,16 +45,21 @@ component extends="wheels.WheelsTest" {
}
}

// Hoisted out of the `finally` below: a for loop over a var inside `finally`
// miscompiles on Lucee 7 (see the finally-loop invariant in CLAUDE.md).
private void function $deleteRows(required array keys) {
for (var key in arguments.keys) {
try {
application.wo.model("CasePreservation").deleteByKey(key = key);
} catch (any e) {
}
}
}

function run() {

describe("order by a reserved-word column (4374)", () => {

beforeEach(() => {
if (variables.isH2) {
skip("the H2 adapter doesn't quote identifiers, so reserved-word columns can't be used");
}
});

it("orders by table.column the same as by the bare column", () => {
var model = application.wo.model("ReservedOrder");
var qualified = model.findAll(order = "#variables.table#.order DESC", returnAs = "query");
Expand All @@ -76,6 +75,72 @@ component extends="wheels.WheelsTest" {

});

describe("H2 identifier case (##4440)", () => {

beforeEach(() => {
if (variables.dialect != "H2") {
skip("identifier folding is an H2 concern");
}
});

it("probes the default fold case as upper", () => {
var adapter = CreateObject("component", "wheels.databaseAdapters.H2.H2Model");
adapter.$init(dataSource = application.wheels.dataSourceName, username = "", password = "");
// The suite's H2 datasource only sets MODE=MySQL, so H2's default folding applies.
expect(adapter.$readIdentifierCase(dataSource = application.wheels.dataSourceName)).toBe("upper");
expect(adapter.$identifierCase()).toBe("upper");
});

it("resolves reads and writes against an unquoted-created table under default folding", () => {
// tests/populate.cfm creates c_o_r_e_casepreservation unquoted with a mixed-case
// `isHidden` column. H2 folds that to ISHIDDEN; the adapter now sends "ISHIDDEN",
// which must still resolve for order=, where= and save() on a schema it did not create.
var model = application.wo.model("CasePreservation");
var names = model.propertyNames();
var hidden = "";
for (var i = 1; i <= ListLen(names); i++) {
if (CompareNoCase(ListGetAt(names, i), "isHidden") == 0) {
hidden = ListGetAt(names, i);
}
}
expect(Len(hidden)).toBeGT(0);

// The fixture is not seeded, and only this spec writes to it.
QueryExecute("DELETE FROM c_o_r_e_casepreservation", [], {datasource = variables.ds});

var created = [];
try {
var highProps = {};
highProps[hidden] = 1;
var high = model.create(properties = highProps);
ArrayAppend(created, high.id);

var lowProps = {};
lowProps[hidden] = 0;
var low = model.create(properties = lowProps);
ArrayAppend(created, low.id);

var byOrder = model.findAll(order = "#hidden# DESC", returnAs = "query");
expect(byOrder.recordCount).toBe(2);
expect(byOrder.id[1]).toBe(high.id);
expect(byOrder.id[2]).toBe(low.id);

var filtered = model.findAll(where = "#hidden# = 0", returnAs = "query");
expect(filtered.recordCount).toBe(1);
expect(filtered.id[1]).toBe(low.id);

var target = model.findByKey(low.id);
target[hidden] = 1;
expect(target.save()).toBeTrue();
var reread = model.findByKey(low.id);
expect(reread[hidden]).toBe(1);
} finally {
$deleteRows(created);
}
});

});

}

}
Loading