The Oracle Backend — Peculiarities¶
Oracle deviates from the SQL standard — and from every other backend Tentackle supports — in more ways than any
other product. Most of those deviations are handled silently by
Oracle8 (the deprecated backend for versions 8 to 11),
Oracle (version 12 and newer) and
Oracle21 (an opt-in for 21c and newer, adding the native
JSON column type) and Oracle23 (an opt-in for 23c and
newer, adding the native BOOLEAN column type and DROP ... IF EXISTS), but a few of them leak into what you
may reasonably expect from your model. This document collects them.
Pick the backend by JDBC URL (
jdbc:oracle:...selectsOracle) and append|Oracle8to pin the legacy dialect,|Oracle21to opt in to native JSON support on 21c and newer, or|Oracle23to additionally opt in to nativeBOOLEANandDROP ... IF EXISTSon 23c and newer.Oraclerefuses to connect to a server older than 12,Oracle21refuses one older than 21,Oracle23refuses one older than 23 (validateVersion).
Filtered Indexes Are Emulated by Function-Based Indexes¶
Oracle supports neither CREATE INDEX ... WHERE <condition> nor any other form of partial index. A model index
carrying a filter condition (Model Definition §7),
such as
is therefore translated into a function-based index over a CASE expression:
The trick works because a row that does not match the condition gets a NULL key, and Oracle never stores
all-NULL keys in a B-tree index. Such rows drop out of the index entirely — which is exactly the semantics of a
partial index, and it makes the conditional uniqueness rule enforceable.
Every index column is wrapped in the same CASE, so a multi-column filtered index also produces an all-NULL key
for non-matching rows:
CREATE UNIQUE INDEX foo_code ON foo (CASE WHEN (type = 0) THEN code END,
CASE WHEN (type = 0) THEN name END);
⚠️ Such an index will not speed up your queries¶
This is the important caveat. A function-based index is only considered by the Oracle optimizer if the query
contains the exact same expression as the index definition — here, the literal
CASE WHEN (type = 0) THEN code END.
Tentackle's select wurblets (PdoSelectList, PdoSelectUnique, and everything derived from the model) generate
ordinary predicates:
Oracle will not match that against the CASE-based index. Consequently:
- On Oracle, the value of a filtered index lies in the constraint it enforces, not in read performance. Use
unique index ... | <condition>when you need conditional uniqueness — that keeps working exactly as on PostgreSQL or SQL Server. - A non-unique filtered index is close to pointless on Oracle. It saves index space, but no generated query
will ever use it. Either declare it
optional(so it is simply skipped on backends that cannot do it well), or drop the filter and accept a full index. - If you really need the index to be used for reads, the query must be hand-written with the identical
CASEexpression. Tentackle does not generate such SQL.
Further consequences of the all-NULL rule¶
- Rows that do match the condition but whose indexed columns are all
NULLare also absent from the index. A query withIS NULLcan never be answered from it. - Unlike PostgreSQL, this is not a property of the filter but of Oracle's B-tree indexes in general; the same is true for any plain multi-column index on Oracle.
Filtered, Function-Based and Descending Indexes Are All Virtual Columns¶
Whenever an index is not a plain list of columns, Oracle materializes a hidden, system-generated virtual column for it. This affects three cases:
| Model | What Oracle really creates |
|---|---|
index x := code \| type = 0 |
index over CASE WHEN ("TYPE"=0) THEN "CODE" END |
index x := upper(name) |
index over UPPER("NAME") |
index x := -code |
index over "CODE" (descending indexes are function-based in Oracle) |
DatabaseMetaData.getIndexInfo() then reports:
COLUMN_NAMEasSYS_NC0000n$instead of the real column, andFILTER_CONDITIONasnull— always, since JDBC has no notion of Oracle's emulation.
Taken at face value, the migrator would compare that against the model, find both the columns and the filter
different, and emit a DROP INDEX + CREATE INDEX pair on every single run of the SQL Maven plugin.
To prevent that, OracleIndexMetaData queries
Oracle's data dictionary for the real expressions:
SELECT COLUMN_POSITION, COLUMN_EXPRESSION FROM ALL_IND_EXPRESSIONS
WHERE TABLE_NAME = ? AND INDEX_NAME = ? [AND INDEX_OWNER = ?]
and OracleIndexColumnMetaData parses each
expression back into the model's view: a column name, an optional function name and — for the CASE form — the
filter condition.
Notes for operators:
- The connecting user needs
SELECTonALL_IND_EXPRESSIONS. That privilege is granted toPUBLICby default, and theALL_view only exposes objects the user may see anyway. COLUMN_EXPRESSIONis aLONGcolumn and must be the last one in the select list — hence the column order above.- Table and index names are passed in uppercase: Tentackle generates unquoted identifiers, which Oracle folds to uppercase, while the metadata model keeps everything in lowercase.
- Oracle's double quotes around identifiers are stripped before the condition is compared with the model's, so
("TYPE"=0)andtype = 0are recognized as equal (the comparison inIndexMigratorignores case, whitespace and matching parentheses).
If you ever see an index being dropped and re-created on every migration run, run the query above by hand and compare its output with the model — that is where the round trip is decided.
Column Defaults Are Stored as Source Text¶
Oracle does not store a column default as a value, but as the verbatim source text of the expression, in the
LONG column ALL_TAB_COLUMNS.DATA_DEFAULT — and that text includes the whitespace up to the token following
the default value. Since Oracle wants DEFAULT before NOT NULL, Tentackle generates:
which Oracle stores — and DatabaseMetaData.getColumns() reports in COLUMN_DEF — as "1 ", with a trailing
blank. Written as a separate statement, the same default ends at the statement terminator and is stored as "1":
Compared literally, "1 " differs from the model's 1, so the migrator would report the default as changed and
emit exactly that MODIFY statement for a table it had just created itself. Executing it "fixes" the table only
because it rewrites the stored text without the blank.
OracleColumnMetaData therefore trims the
default before it is compared. Only the text outside the quotes is removed, so the single blank Oracle uses for
the empty string (see below) survives as ' '.
Every column that is NOT NULL and has a default is affected — most notably serial, which every entity
carries.
Defaults that are not written the way the model spells them¶
Two model values do not reach the DDL unchanged, because valueToLiteral rewrites them for Oracle:
| Model | DDL | Reason |
|---|---|---|
[default ""] |
DEFAULT ' ' |
Oracle cannot distinguish '' from NULL, so the empty string is a single blank |
[default true] |
DEFAULT 1 |
booleans are NUMBER(1) |
Comparing the model value with the stored text directly would therefore fail — "" against ' ', TRUE against
1 — and unlike the trailing-blank case above, re-applying the MODIFY would not settle it: the mismatch
would be reported on every single migration run, forever. Oracle8.isDefaultEqual() applies the same mapping to
the model value before comparing. Oracle23, which declares booleans as native BOOLEAN columns, writes and
compares the defaults as TRUE/FALSE instead.
Other Oracle Peculiarities¶
These are older, and are implemented in Oracle8 (and thus inherited by Oracle):
- The empty string is
NULL. Oracle cannot distinguish''fromNULL, sogetEmptyString()returns a single blank. An attribute set to""is stored — and read back — as" ". - No schemas.
isSchemaSupported()returnsfalse: in Oracle a "schema" is a user, so Tentackle does not generate schema-qualified DDL. A model using schemas must map them — see Table Names, which applies to MySQL and MariaDB as well. - 30-character identifiers.
getMaxNameLength()returns 30 for every kind of name. Long entity or index names in the model will be rejected at build time. - No leading underscore. Identifiers must not start with
_, so temporary objects created during a migration are prefixedtmp_instead of_tmp(isTemporaryName). Identifiers containing$are treated as reserved. - Booleans are
NUMBER(1), unless you opt in toOracle23. So areBITandTINYINT;SMALLINT/INTEGER/BIGINTbecomeNUMBER(5|10|19).valueToLiteralrenderstrue/falseas1/0. Oracle's nativeBOOLEANtype only exists from 23c, soOracle/Oracle21keep emittingNUMBER(1)for correctness across every version they cover (see below). DEFAULTcomes beforeNOT NULLin a column declaration (see Column Defaults Are Stored as Source Text), and there is no realDROP DEFAULT— Tentackle emitsDEFAULT NULL, which is functionally but not structurally equivalent (seeOracleColumnMetaData).ALTER TABLEneeds parentheses —ADD (...)/MODIFY (...)instead ofADD COLUMN/ALTER COLUMN.- An extra commit is required (
isExtraCommitRequired()), andSELECTwithout a table needsFROM DUAL. - Paging uses
OFFSET ... ROWS FETCH NEXT ... ROWS ONLYinOracle, but the nestedROWNUMconstruct inOracle8. - XML is supported, but needs
xdb.jarandxmlparserv2.jaron the classpath. - JSON is stored as a
CLOB, unless you opt in toOracle21. Oracle's nativeJSONtype only exists from 21c, whileOraclecovers 12 and newer andsqlTypeToStringhas no version information at DDL-generation time. ACLOBis readable by every supported version, soOracle.isJsonSupported()staysfalseand the document is bound as a plain string. If your server is 21c or newer, append|Oracle21to the connection URL:Oracle21.isJsonSupported()istrue,sqlTypeToStringemitsJSONinstead ofCLOB, andjdbcTypeToSqlTyperecognizes an existing nativeJSONcolumn (reported asTypes.OTHERwith type name"JSON") so the migrator does not try to recreate it on every run.Oracle21.validateVersionrefuses to connect to a server older than 21. DROP ... IF EXISTSis not emulated — a plainDROPis used, and fails if the object is missing — unless you opt in toOracle23.Backend.isDropIfExistsEnabled()(used bysqlDropTable,sqlDropIndex,sqlDropColumnandsqlDropConstraintto make migration scripts idempotent) is gated byisDropIfExistsSupported(), whichOracle/Oracle21never override, so it staysfalse: the SQL plugin'suseDropIfExistsoption silently falls back to a plainDROPagainst Oracle, even on a 21c/23c server. Oracle only addedDROP ... IF EXISTS(andCREATE ... IF NOT EXISTS) in 23c. If your server is 23c or newer, append|Oracle23to the connection URL:Oracle23.isDropIfExistsSupported()istrue, so the generated DDL includesIF EXISTSwheneveruseDropIfExistsis requested.Oracle23.validateVersionrefuses to connect to a server older than 23.- Booleans become the native
BOOLEANtype withOracle23. On top ofOracle21's JSON support,Oracle23.sqlTypeToStringemitsBOOLEANinstead ofNUMBER(1)forSqlType.BIT, andvalueToLiteralrenderstrue/falseas the bare SQL literalsTRUE/FALSEinstead of1/0.TINYINTis unaffected and staysNUMBER(1), since it is a small integer type, not a boolean. Reading an existing nativeBOOLEANcolumn back needs no override: the JDBC driver reports it asTypes.BOOLEAN, which every backend already maps toSqlType.BITby default.
Related Documentation¶
- Tentackle SQL — the backend abstraction this backend implements.
- Table Names — schema mapping and table prefixes, required on Oracle because it has no usable schemas.
- Model Definition — the index syntax, including filter conditions.
- Tentackle SQL Maven Plugin — generates the DDL and the migration scripts discussed above.