SQL and PL/SQL Coding Standards Copy link
Plugin: Blitz Report Development (
coding-standards)Works with: Claude Code
Type: Rules loaded automatically when a task needs them
SQL and PL/SQL coding standards for Blitz Report content: report SQL, LOV queries, upload packages.
When to use it
- Use whenever writing or reviewing SQL or PL/SQL for a Blitz Report or Blitz Upload.
Claude Code
- Add the Enginatics marketplace in Claude Code.
/plugin marketplace add enginatics/ai-skills - Install the plugin.
/plugin install blitz-report-ai-skills@enginatics - Run the environment setup once; it describes your EBS development instance (never production).
/blitz-report-ai-skills:blitz-environment-setup - Nothing to run: Claude loads these rules automatically when a task needs them.
- Keep it current: enable auto-update for the enginatics marketplace, or update manually.
/plugin marketplace update enginatics
Requirements: Blitz Report installed on your Oracle EBS development instance, and database access from Claude Code to that instance (Oracle SQLcl MCP server, SQLcl or SQL*Plus). Never point the skills at production.
Self-audit every SQL / PL-SQL / VBA edit against these rules before presenting or deploying it — including one-off operator, migration and test scripts, which are written standards-compliant from the first draft (never "functional first, restyle later"). Most-missed: alias every table including the delete/update/insert target and qualify every column (#5/#6); a space around =; a redundant nvl; != instead of <>.
PL/SQL Coding Standards
No redundant brackets or code - AND has higher precedence than OR, so inner brackets around AND conditions are redundant
-- Bad if ((:xxen_reports_run_v.type is not null) and (:xxen_reports_run_v.type='U')) then -- Good if :xxen_reports_run_v.type='U' then -- Bad (redundant inner brackets - AND binds tighter than OR) (xu.order_number=p_order_number or (xu.order_number is null and p_order_number is null)) -- Good (xu.order_number=p_order_number or xu.order_number is null and p_order_number is null)No redundant nvl()
-- Bad if nvl(fnd_profile.value('XXEN_REPORT_DEBUG'),'N')='Y' then -- Good if fnd_profile.value('XXEN_REPORT_DEBUG')='Y' thennvl(col, sentinel) = literalis redundant BECAUSE OF the=operator, NOT because the column is non-null — do not "verify the column can't be null" to justify removing it. Whensentinel <> literal, a null column already fails the equality (null='VALID'is not true) and so does the sentinel (nvl(null,'X')='VALID'->'X'='VALID'-> false): identical result whether or not nulls occur. Just writecol=literal.- Same for the negation when
sentinel = literal:nvl(col,'INVALID')<>'INVALID'is redundant (null and 'INVALID' both give false) ->col<>'INVALID'. - The one case where the nvl is NOT redundant is a negation with
sentinel <> literal:nvl(col,'X')<>'VALID'flags a null row ('X'<>'VALID'is true) whereas barecol<>'VALID'does not (null<>'VALID'is null/not-true). Keep it there only if nulls can occur AND you want them flagged; otherwise (column provably non-null for the filtered set) removing it is correct and keeping it is a guard that can't fire (rule #50).
No redundant else null in case statements
-- Bad case when x.shared_string='s' then to_number(x.shared_string_id) else to_number(null) end -- Good case when x.shared_string='s' then to_number(x.shared_string_id) endOne
whenclause per line in case statements- Keep
whenandthenon the same line when the statement is simple enough to fit - Split onto separate lines only when the
whencondition orthenexpression is complex and doesn't fit readably on one line-- Bad (multiple when clauses on one line): when 1 then p_attribute1 when 2 then p_attribute2 when 3 then p_attribute3
-- Good (simple - when/then on same line): when 1 then p_attribute1 when 2 then p_attribute2 when 3 then p_attribute3
-- Good (complex - split for readability): when p_status='ACTIVE' and p_end_date is null then some_long_function_call(p_param1, p_param2, p_param3)
- Keep
Space between closing bracket and keywords
-- Bad: )loop -- Good: ) loopAlways use table aliases — even on single-table queries — and qualify every column with its alias
-- Bad (no alias) select 1 from dual where exists (select 1 from xxen_report_sql_columns) -- Good (alias on the only table) select null from xxen_report_sql_columns xrsc where rownum=1 -- Bad (single-table query without alias) select 'Y' from fnd_irep_classes where irep_name='XXEN_WEBSERVICES' and deployed_flag='Y'; -- Good (alias plus qualified columns) select 'Y' from fnd_irep_classes fic where fic.irep_name='XXEN_WEBSERVICES' and fic.deployed_flag='Y'; -- Bad (alias declared but not all columns qualified) select count(*) from user_ords_roles uor where name='xxen_webservices'; -- Good select count(*) from user_ords_roles uor where uor.name='xxen_webservices';The only exception is
dual, which is conventionally referenced without an alias when no column is selected from it (select sysdate from dual,select 'X' from dual). Any other table — including one-off existence checks, scalar lookups inselectlists, and queries against data-dictionary views — must have an alias and column-qualified references. Applies equally to PL/SQLselect … intostatements,for c in (...)cursors, and SQL embedded in shell here-docs.Use correct table alias conventions
- Alias is formed from first letter of each word part in the table name
- Include the _v suffix for views
- Exception: if alias would be a reserved keyword, keep full short form (e.g.
ap_suppliers→apsnotas) - When table name changes, alias must change accordingly
- When joining the same table multiple times, add descriptive suffix
-- xxen_reports -> xr -- xxen_reports_tl -> xrt -- xxen_report_parameters -> xrp -- xxen_report_run_param_values -> xrrpv (not xrrpvt) -- xxen_report_dynamic_col_rules -> xrdcr -- xxen_report_dyn_col_rule_rpts -> xrdcrr -- ra_rules -> rr -- ra_terms -> rt -- wsh_carrier_ship_methods_v -> wcsmv (include _v for views) -- qp_list_headers_vl -> qlhv -- hz_cust_site_uses_all -> hcsua -- po_headers_all -> pha -- mtl_system_items_b_kfv -> msibk (NOT mtl_system_items_kfv — always use _b_kfv directly) -- ap_suppliers -> aps (not 'as' — reserved word)
-- When same table joined multiple times, add suffix: -- ra_rules for accounting rule -> rr_a -- ra_rules for invoicing rule -> rr_i -- hz_cust_site_uses_all for ship_to -> hcsua_ship -- hz_cust_site_uses_all for bill_to -> hcsua_bill -- wsh_carrier_ship_methods_v for line -> wcsmv_ln -- gc_code_combinations_kfv multiple joins -> gcck1, gcck2
Two blank lines BEFORE AND AFTER each top-level procedure in the package body — consistently. Single blank line within procedures and in the spec.
- Two blanks above the first procedure (after the package globals / private helpers).
- Two blanks between every pair of top-level procedures.
- Two blanks after the last procedure, before
end <pkg_name>;. - The point is visual symmetry: each top-level procedure stands out the same way, with two blank lines separating it from whatever comes before AND after. Not 1, not 3.
- No blank lines between
procedure X is(orfunction X is) and the first declaration /begin. Declarations start immediately on the next line. Same for nested procedures. - In the spec: single blank line between procedure declarations.
No standalone comment separator lines
-- Bad -- -- comment -- Good (just blank line before comment) -- commentNo spaces around assignment or comparison operators
-- Bad: l_row := l_row + 1; -- Good: l_row:=l_row+1; -- Bad: nvl(ac.status,'A') <> 'I' -- Good: nvl(ac.status,'A')<>'I' -- In XML: use <> with no surrounding spacesUse bind variables instead of literals in dynamic SQL
-- Bad: 'where xur.run_id='||g_upload_run_id -- Good: 'where xur.run_id=:g_upload_run_id'Package spec procedure order must match body order
- The order of function/procedure declarations in the package spec MUST exactly match the order of their implementations in the package body
- When adding a new public function, find where it's located in the body and add the spec declaration at the corresponding position
- This is a PL/SQL requirement - mismatched order causes compilation errors
-- Example: If body has functions in order: func_a, func_b, func_c -- Then spec MUST have them in same order: func_a, func_b, func_c -- NOT: func_a, func_c, func_b (wrong order - will fail to compile)
Simplify nested loops - combine into single query where possible
CRITICAL: No code duplication - This is a fundamental rule. Code must be minimalistic.
- Never create a new procedure/function when similar logic already exists - refactor and reuse
- When adding functionality that exists elsewhere, move the existing code to a shared location
- If a local/nested procedure needs to be called from multiple places, move it to package level
- When fixing a bug, ensure the fix applies to all places using that logic (should be one place)
- Before writing new code, search for existing implementations that can be extended
- If you find yourself copying code, STOP and refactor into a single reusable unit
No unnecessary global variables or functions - use local scope
- Helper functions/procedures used by only one parent procedure should be defined locally within that procedure's declare section, not at the package body level
- This keeps scope tight, makes dependencies clear, and avoids polluting the package namespace
Use EBS standard types (fnd_table_of_varchar2_4000) instead of custom record types when possible
Use Windows line endings (CRLF) for SQL/PL/SQL files, and NO UTF-8 BOM. Files must start with the first byte of
create or replace ..., not the 3-byte BOM (EF BB BF). SQL*Plus does not strip the BOM; it interprets it as part of the first command and emitsSP2-0734: unknown command beginning '<feff>create or...'with all subsequent declarations following also rejected as garbage. Verify after writing/editing a.pkb/.pks:file path/to/X.pkb # must say "UTF-8 text" or "ASCII text", NOT "UTF-8 (with BOM) text"To strip an existing BOM in-place while preserving CRLF (Python; on Linux:
sed -i '1s/^\xef\xbb\xbf//'):with open(path,'rb') as f: data=f.read() if data.startswith(b'\xef\xbb\xbf'): with open(path,'wb') as f: f.write(data[3:])Avoid exception handling unless required - Let errors propagate with full stack trace rather than catching and logging
- When
when othersIS needed, always log the full error stack — never justsqlerrm(which loses line numbers and the call stack)-- Bad (loses stack trace): exception when others then write_log('error: '||sqlerrm);
-- Good (full stack with line numbers): exception when others then write_log('error: '||dbms_utility.format_error_stack||dbms_utility.format_error_backtrace);
- When
Consistent log/error messages in proper English
-- Bad: write_log ('p_source_line_id :'||p_source_line_id); -- Good: write_log('p_source_line_id: '||p_source_line_id);All code must be lowercase - including function parameters like userenv('lang')
-- Bad: SELECT * FROM ... userenv('LANG') -- Good: select * from ... userenv('lang')- The rule governs CODE tokens (keywords, identifiers, function names) — NOT case-significant string-literal DATA. A literal whose exact case is part of a stored value or wire contract keeps its case: parameter/column type codes (
'Date','DateTime','Number','Char','LOV'— the values stored inxxen_report_parameters.parameter_typeand sent in the add-in'sFbind field), the engine's semantic markers ('<multiple_values>','<null>','<id>'), display meanings, and any value compared against case-sensitive data. Lowercasing these breaks the comparison. Theuserenv('lang')example is lowercased only because that particular argument is case-insensitive.
- The rule governs CODE tokens (keywords, identifiers, function names) — NOT case-significant string-literal DATA. A literal whose exact case is part of a stored value or wire contract keeps its case: parameter/column type codes (
No redundant get_ prefix in function names
-- Bad: get_site_use_id, get_new_value, get_inventory_item_id -- Good: site_use_id, new_value, inventory_item_idUse traditional Oracle SQL syntax, not ANSI
-- Bad (ANSI): select * from t1 left join t2 on t2.id=t1.id -- Good (traditional): select * from t1, t2 where t2.id(+)=t1.id- Exception: a genuine full outer join uses ANSI
full join, confined to its own inline view that joins only the keys, while the rest of the statement stays traditional (Oracle rejects(+)and ANSI joins in the same query block). Typical case: two child lists sharing a key, which outer-joined to their parent separately multiply each other.
- Exception: a genuine full outer join uses ANSI
Use xxen_util functions for lookup translations instead of querying lookup tables
xxen_util.meaning(code, lookup_type, application_id)- code to meaningxxen_util.lookup_code(meaning, lookup_type, application_id)- meaning to code- Works for fnd_lookup_values, oe_lookups (660), ar_lookups (222), etc.
-- Bad (querying lookup table): select ol.lookup_code from oe_lookups ol where ol.lookup_type='FOB' and ol.meaning=p_meaning and rownum=1 -- Good (using function): xxen_util.lookup_code(p_meaning,'FOB',660)
-- Bad (joining to lookup table): from fnd_lookup_values flv where flv.lookup_type='FREIGHT_TERMS' and flv.lookup_code=ooha.freight_terms_code select flv.meaning -- Good (using function): select xxen_util.meaning(ooha.freight_terms_code,'FREIGHT_TERMS',660)
- For FND lookups in LOV queries: usefnd_lookup_values_vl(flvv) withsecurity_group_id=0andenabled_flag='Y'— NOTfnd_lookup_valueswith language filterWHERE clause formatting - Each restriction on a new line, join direction flows left to right matching FROM clause order
- Outer join (+) goes on the right side, matching the natural reading flow from driving table to lookup table
- Exception: when joining to constant values like
rtt.language(+)=userenv('lang'), the (+) stays on the left-- Bad (wrong direction, uppercase LANG): where ooha.header_id(+)=xu.header_id and qlhv.list_header_id(+)=ooha.price_list_id and rtt.language(+)=userenv('LANG') -- Good (left to right flow, lowercase lang): where xu.header_id=ooha.header_id(+) and ooha.price_list_id=qlhv.list_header_id(+) and rtt.language(+)=userenv('lang')The same left-to-right flow applies to correlated scalar subqueries: the restriction coming from the outer query drives the subquery, so the outer value goes on the LEFT:
-- Bad (outer correlation on the right): (select cbv.bank_name from ce_banks_v cbv where cbv.bank_party_id=ieba.bank_id) bank_name, -- Good (outer correlation on the left): (select cbv.bank_name from ce_banks_v cbv where ieba.bank_id=cbv.bank_party_id) bank_name,
Indentation: declare section flat, begin section indented 2 spaces
- Everything between
declareandbegin(variables, types, inline functions) has NO indentation — sits at the same level asdeclare - Everything between
beginandendis indented 2 spaces frombegin— no exceptions. The first statement afterbeginmust be indented. Nested blocks (loop body, if body) add another 2 spaces. - This applies at every nesting level: inline functions in a declare block follow the same rule — their own declare section is flat, their own begin section is indented 2
- Parameters and local variables use same indentation as procedure/function name
- Procedure/function headers on one line:
function foo return boolean is - No alignment padding on local variable declarations
Verification: After writing or editing a procedure, check that no line between
beginandendhas zero indentation (exceptexception,end, and multi-line string literal continuation lines)-- Bad (declare section indented): declare l_sql clob; l_error varchar2(4000); function decode_b64(p_b64 varchar2) return clob is begin return utl_raw.cast_to_varchar2(...); end; begin l_sql:=decode_b64(:input);
-- Good (declare section flat, begin section indented 2): declare l_sql clob; l_error varchar2(4000);
function decode_b64(p_b64 varchar2) return clob is begin return utl_raw.cast_to_varchar2(...); end; begin l_sql:=decode_b64(:input);
-- Same rule for named procedures/functions: -- Bad (indented parameters and variables): function default_header_value( p_attribute_code in varchar2, p_operating_unit in varchar2 default null ) return varchar2 is l_header_rec oe_ak_order_headers_v%rowtype; l_default_id number; begin
-- Good (no extra indentation): function default_header_value( p_attribute_code in varchar2, p_operating_unit in varchar2 default null ) return varchar2 is l_header_rec oe_ak_order_headers_v%rowtype; l_default_id number; begin
-- For calls, parameters match the call's indentation: process_sales_order( p_header_rec=>l_header_rec, p_line_tbl=>l_line_tbl );
- Everything between
Minimalistic SQL - no redundant null checks
- Equality conditions naturally handle nulls (null = anything is never true)
- Don't add explicit "is not null" checks when the value is already used in an equality condition
-- Bad (redundant null checks): where xu.order_number is not null and xu.order_type is not null and ooha.order_number=xu.order_number and ottt.name=xu.order_type -- Good (equality handles nulls): where ooha.order_number=xu.order_number and ottt.name=xu.order_type
Understand unique keys - no redundant rownum=1
- Know which columns have unique indexes/constraints and don't add
rownum=1when the query can only return one row - Example:
hr_all_organization_units.nameis unique, so norownum=1needed when filtering by name-- Bad (redundant rownum on unique column): select haouv.organization_id from hr_all_organization_units_vl haouv where haouv.name=xu.operating_unit and rownum=1 -- Good (name is unique): select haouv.organization_id from hr_all_organization_units_vl haouv where haouv.name=xu.operating_unitThis applies equally to correlated scalar subqueries: do not add a defensive
rownum<=1"for safety" when the correlation key is unique (e.g.ce_banks_v.bank_party_id) — same family as rule #50, a guard that cannot fire hides a broken assumption.
- Know which columns have unique indexes/constraints and don't add
*Use subqueries with x/y/z aliases instead of
with ... asblocks (CTEs), and use to avoid repeating columns**- Prefer nested subqueries over
with ... asblocks - Use short aliases (x, y, z) for subquery levels
- Use
select x.*to pass through columns instead of listing them all - When a subquery is written in multiline style, put the opening
(on its own line and startselecton the next line — never(selecton the same line. (A subquery kept entirely on one line is fine as(select ... ) alias; this only applies once it spans multiple lines.)-- Bad (multiline subquery with '(select' on the same line): (select msit.description from mtl_system_items_tl msit where msit.inventory_item_id=x.item_id and msit.language=userenv('lang') ) item_description
-- Good ('(' on its own line, select on the next): ( select msit.description from mtl_system_items_tl msit where msit.inventory_item_id=x.item_id and msit.language=userenv('lang') ) item_description
-- Good (short subquery stays on one line): (select ppx.full_name from per_people_x ppx where ppx.person_id=x.buyer_id and rownum=1) buyer
```sql -- Bad (with ... as blocks, repeating columns): with base as ( select xu.col1, xu.col2, xu.col3, ... from table xu ), enriched as ( select base.col1, base.col2, base.col3, ..., extra from base ) select enriched.col1, ... from enriched -- Good (nested subqueries with * and x/y/z): select y.*, ooha.extra_col from ( select x.*, (select ...) resolved_id from (select xu.* from table xu) x ) y, other_table ooha where y.resolved_id=ooha.id(+)- Prefer nested subqueries over
No redundant 'as' keyword for column aliases
-- Bad: select count(*) as order_line_count, min(x.upload_row) as order_sort -- Good: select count(*) order_line_count, min(x.upload_row) order_sortUse cursor for loop instead of select into with exception handling
- Shorter and more elegant than begin/exception/end blocks
- Loop naturally handles no_data_found (just doesn't execute)
- For multi-line SQL, keep same indentation level as the
forstatement-- Bad (verbose with exception handling): begin select mp.organization_id into l_org_id from mtl_parameters mp where mp.organization_code=p_warehouse; exception when no_data_found then null; end;
-- Good (cursor for loop, single line): for c in (select mp.organization_id from mtl_parameters mp where mp.organization_code=p_warehouse) loop l_org_id:=c.organization_id; end loop;
-- Good (multi-line SQL, same indentation as for): for c in ( select ooha.header_id from oe_order_headers_all ooha where ooha.order_number=p_order_number ) loop return c.header_id; end loop;
-- Bad (SQL indented further than for): for c in ( select ooha.header_id from oe_order_headers_all ooha ) loop
No redundant nvl() or implicit type conversions
- Comparisons handle null naturally (null > 0 is false, not an error)
- Numeric comparisons do implicit to_number conversion
-- Bad (redundant nvl and to_number): nvl(to_number(c.debug_level),0)>0 -- Good: c.debug_level>0
-- Bad (redundant nvl - null > 0 is already false): nvl(l_count,0)>0 -- Good: l_count>0
SQL formatting - one line per select column, from table, where clause, insert column, values expression
- One column per line after
select, one table per line afterfrom, one condition per line afterwhere insert into <table> ( ... ) values ( ... )— one column name per line in the column list, one value/expression per line in the values list. Never pack multiple columns or values on the same line.- Applies everywhere: main SQL, LOV queries in XML, Success SQL, PL/SQL cursors, DML statements
- Only use single-line format for very small simple queries (e.g.
select mp.organization_id from mtl_parameters mp where mp.organization_code=p_code) - FROM clause table order must follow the restriction flow — the table restricted first (by bind variable, constant, or driving condition) comes first
-- Bad (columns and tables on same line as keywords): select ooha.header_id from oe_order_headers_all ooha,oe_transaction_types_tl ottt where ooha.order_number=p_order_number
-- Good (separate lines): select ooha.header_id from oe_order_headers_all ooha, oe_transaction_types_tl ottt where ooha.order_number=p_order_number and ooha.order_type_id=ottt.transaction_type_id
-- OK for very small queries (single line): for c in (select mp.organization_id from mtl_parameters mp where mp.organization_code=p_code) loop
-- Bad (insert with packed columns/values): insert into qa_plan_char_actions ( plan_char_action_id, plan_char_action_trigger_id, action_id, assigned_char_id, assign_type, message, creation_date, created_by, last_update_date, last_updated_by, last_update_login ) values ( l_plan_char_action_id, l_plan_char_action_trigger_id, p_action_id, p_assigned_element_id, p_assign_type, p_action_message, sysdate, fnd_global.user_id, sysdate, fnd_global.user_id, fnd_global.login_id );
-- Good (insert with one column / value per line): insert into qa_plan_char_actions ( plan_char_action_id, plan_char_action_trigger_id, action_id, assigned_char_id, assign_type, message, creation_date, created_by, last_update_date, last_updated_by, last_update_login ) values ( l_plan_char_action_id, l_plan_char_action_trigger_id, p_action_id, p_assigned_element_id, p_assign_type, p_action_message, sysdate, fnd_global.user_id, sysdate, fnd_global.user_id, fnd_global.login_id );
- One column per line after
No hardcoded user-facing values - use lookup codes for multi-language support
- Never hardcode translated/translatable values like 'Book Order', 'Cancel', etc.
- LOVs display
xxen_util.meaning(code, lookup_type, app_id)to users - In PL/SQL, use
xxen_util.lookup_code(meaning, lookup_type, app_id)to convert back to code - Compare against the lookup CODE (e.g., 'BOOK', 'CANCEL'), not the translated meaning
-- Bad (hardcoded translated value - breaks in other languages): if l_order_action='Book Order' then
-- Good (convert meaning to code, compare against code): if xxen_util.lookup_code(l_order_action,'ORDER ACTION',660)='BOOK' then
- **An error message a user sees comes from the Oracle message dictionary wherever one exists** -fnd_message.get_string('<application>','<message name>'), orfnd_message.set_name+getwhere the message takes tokens. It is already translated and already says what the user is used to reading, and a rejection our code raises is then indistinguishable from the one the standard program would have raised for the same condition. Reproduce an Oracle validation ourselves (because an interface or api cannot see the rows) and reuse its message anderror_codeverbatim. Write a literal only when no seeded message fits. ```sql -- Bad (our own wording for a condition Oracle already names): message_:='Costs already exist for this item and cost type.'; -- Good (the message the Cost Import itself writes): message_:=substrb(fnd_message.get_string('BOM','CST_CANT_INSERT'),1,240);Only one space between column expression and alias
- No alignment padding with multiple spaces
- Remove alias if it matches the column name
-- Bad (padded alignment, redundant alias): haouv.name operating_unit, rcta.trx_number trx_number, rctla.unit_selling_price unit_price, -- Good: haouv.name operating_unit, rcta.trx_number, rctla.unit_selling_price unit_price,
Use
<>not!=for not-equal comparisons. In XML files, escape as<>with no surrounding spaces.Use coalesce for 3+ fallback values instead of nested nvl
-- Bad (nested nvl): nvl(a, nvl(b, c)) -- Good (coalesce): coalesce(a, b, c)Two values is fine with
nvl. Usecoalescewhen there are 3 or more fallback values, and always when a fallback is a scalar subquery or function call:nvlevaluates its second argument for every row even when the first is not null (measured: the subquery's full reads with a non-null first argument), whilecoalescestops at the first non-null value.PL/SQL string truncation — substr vs substrb
- Use
substrbonly when truncating to a variable's byte limit (e.g. 32767 for varchar2). With AL32UTF8, 32767 chars can be 131068 bytes → ORA-06502. Usesubstrb(text,1,32767)instead. - Use
substrwhen truncating to an arbitrary processing limit (e.g. 4000). You want N characters of content, not bytes. - Rule of thumb: Is the limit the target variable's max size? →
substrb. Is it a "good enough" processing limit? →substr.
- Use
36a. ORA-01489: result of string concatenation is too long is raised only by || / concat / listagg — under max_string_size=STANDARD (the EBS default, 4000-byte SQL varchar2) the other string functions (replace, regexp_replace, rpad, lpad, substr) silently truncate at 4000 instead. Corollaries:
- A PL/SQL function returning
varchar2is treated asvarchar2(4000)when called from SQL: a longer result raisesORA-06502at the call site, so any value that comes back is already ≤4000 bytes. - The classic trap is
'|'||some_function(...)||'|' like '%|FOO|%'on a value near 4000 bytes: the two extra characters overflow. Rewrite the pipe-anchoredlikeas bare patterns (x like '%|FOO|%' or x like 'FOO|%' or x like '%|FOO' or x='FOO'), orsubstrb(value,1,3900)before any||. - Signature in a report log:
ERROR fetching datawith a stackORA-01489 → SYS.DBMS_SQL → APPS.XXEN_REPORT (dbms_sql.fetch_rows)— the framework is only the messenger, the offending||is in the report/upload SQL, and it fails only on the rows whose value is that long (a filtered test run passes).
Minimalistic code is paramount
- Eliminate unnecessary variables, redundant queries, and extra logic
- Reuse a single variable rather than declaring separate ones
- Return directly from loops instead of storing in a variable then returning
if condition then return 'N'; end if; return 'Y';is cleaner thanif/else return
Always use the highest-level Oracle standard API — before writing direct DML or low-level table operations, search for official Oracle APIs in this order: (1) Public APIs (
*_PUB,*_API), (2) Standard utilities/commands (e.g.FNDGFUfor fnd_lobs uploads,FNDLOADfor setup data), (3) Table handlers (*_PKG). Example: for fnd_lobs uploads, use theFNDGFUcommand (likebin/import_xmldoes) — never direct INSERT into fnd_lobs.Product name is "Blitz Report" — always use the full name "Blitz Report" in labels, descriptions, comments, and user-facing strings. Never abbreviate to just "Blitz" unless it’s an established exception (e.g. profile options like "Blitz Upload", "Blitz FSG" for readability).
-- Bad: p_label=>'Blitz Webservices' -- Good: p_label=>'Blitz Report Webservices'Never display raw Y/N flag values — always translate using
xxen_util.meaning- Report display convention: show only "Yes", leave blank for "No" — users don't want to see a wall of "No" values; a blank cell is clearer. Use the dedicated helper
xxen_util.yes(col)for Y/N flag columns — it returns the translated "Yes" for 'Y' and null (blank) for anything else. It is defined inXXEN_UTILas exactlydecode(p_lookup_code,'Y',meaning('Y','YES_NO',0)), so always prefer the helper over hand-writing thedecode(col,'Y',xxen_util.meaning('Y','YES_NO',0))form. - For CASE WHEN conditions:
xxen_util.yes(case when condition then 'Y' end)(omitelse 'N') - Applies to all boolean/flag columns:
primary_flag,dependent_flag,beneficiary_flag,approved_flag,billable_flag,reverse_flag,capitalized_flag,released,transferred, etc.-- Bad (shows "No" for every non-Y row): xxen_util.meaning(pcr.dependent_flag,'YES_NO',0) dependent, xxen_util.meaning(ppaa.reverse_flag,'YES_NO',0) reverse_flag, case when pdia.released_date is not null then 'Y' else 'N' end released,
-- Good (shows "Yes" or blank — use the dedicated xxen_util.yes() helper): xxen_util.yes(pcr.dependent_flag) dependent, xxen_util.yes(ppaa.reverse_flag) reverse_flag, xxen_util.yes(case when pdia.released_date is not null then 'Y' end) released,
- **Storage convention for new boolean columns** (Enginatics-owned tables): store'Y'ornull— never'N'. Two-valued logic only, no Y/N/null tri-state. On insert/update convert incoming'N'to null withnullif(p_flag,'N'); on display translate withxxen_util.meaning(nvl(col,'N'),'YES_NO',0)so blanks render as language-specific "No". Does NOT apply to existing Oracle EBS flag columns that already use'N'— keep their semantics.- Report display convention: show only "Yes", leave blank for "No" — users don't want to see a wall of "No" values; a blank cell is clearer. Use the dedicated helper
Never wrap indexed date columns with trunc() or to_char() — this prevents index access and forces full table scans
- For "date from" filters:
column>=:date_from(no trunc) - For "date to" filters:
column<:date_to+1(before midnight the next day, no trunc) - This pattern captures the full day including any time component, while preserving index access
-- Bad (kills index on transaction_date): trunc(rt.transaction_date)>=:date_from trunc(rt.transaction_date)<=:date_to
-- Good (index-friendly): rt.transaction_date>=:date_from rt.transaction_date<:date_to+1
- For "date from" filters:
Default cached id-lookup variables to an impossible sentinel (e.g.
-99), not null, when they will be used inNOT INclauses- When replacing a
user_name/name→id subquery with a cached variable (e.g.l_enginatics_user_id), initialize to-99rather than leaving nullable - Reason:
col not in (-1, null)evaluates to null → filters ALL rows out. The originaluser_name not in ('ANONYMOUS','ENGINATICS')correctly returned rows when the user didn't exist — a null variable would silently break that semantic on instances where the lookup returns no row.-- Bad (breaks NOT IN on instances where ENGINATICS user doesn't exist): l_enginatics_user_id number; for c in (select fu.user_id from fnd_user fu where fu.user_name='ENGINATICS') loop l_enginatics_user_id:=c.user_id; end loop; -- ... where xrp.created_by not in (-1,l_enginatics_user_id) -- null → no rows pass
-- Good (sentinel default): l_enginatics_user_id pls_integer:=-99;
- When replacing a
Line wrapping: don't wrap proactively, and put connectors at END of line
- Line length budget: assume wide screens. Don't wrap until a line clearly exceeds ~200–240 characters. Wrapping shorter lines hurts readability, not helps. Sweet spot: keep related logic together on one line where possible, only break when the line genuinely won't fit.
- Connector position when you DO wrap:
and/orgo at the END of the previous line, never at the START of a new line. This is consistent with rule #23 for SQL WHERE clauses, generalized to PL/SQLif/while/exit whenconditions and SQL HAVING/ON clauses.-- Bad — connector at start of line: if l_x is not null and l_y is not null and l_z is not null then
-- Bad — wraps too aggressively when one line would fit: if l_x is not null and l_y is not null and l_z is not null then
-- Good — fits on one line, no wrap: if l_x is not null and l_y is not null and l_z is not null then
-- Good — when wrap is genuinely needed, connector at end: if l_x is not null and l_y is not null and l_action=xxen_upload.g_action_create or xxen_upload.value_modified('p_x') or xxen_upload.value_modified('p_y') then
Prefer language views (
_vl) over manual_b + _tljoins. Oracle EBS provides_vlviews (e.g.fa_additions_vl,mtl_system_items_vl,hr_all_organization_units_vl,fnd_lookup_values_vl) that already join the base table to its_tltranslations table foruserenv('lang'). Using the_vlview is shorter, more elegant, and harder to get wrong.-- Bad (manual _b + _tl join): from fa_additions_b fab, fa_additions_tl fat where fab.asset_id=fat.asset_id and fat.language=userenv('lang') and fab.in_use_flag='Y' -- Good (language view): from fa_additions_vl fav where fav.in_use_flag='Y'Applies in main SQL, LOV queries, Success SQL, PL/SQL cursors. The handful of cases where you do need the underlying
_b(e.g. you need rowid forxxen_util.display_flexfield_value, or you need a non-translated column not exposed by_vl) are exceptions — document why in a comment.Reinforces existing rule #33: when you switch to a
_vlview, the now-redundant column aliases (e.g.fav.description description) must be removed.Items: use
mtl_system_items_vl msiv— NOTmtl_system_items_b_kfv msibk— for any query that displays the item DESCRIPTION.mtl_system_items_b_kfvis built on the base tablemtl_system_items_b, so itsdescriptionis the untranslated (install-base-language) value; in a non-US session it shows the wrong language.mtl_system_items_vlis the language view (_b+_tlforuserenv('lang')) and — it also carriesconcatenated_segmentsplus all the base columns an item LOV needs (recipe_enabled_flag,serial_number_control_code,primary_uom_code,organization_id,inventory_item_id). Somtl_system_items_vlis a complete drop-in replacement: one view gives both the KFF concat (the item "value") AND the translated description — never join_b_kfvfor segments + something else for the description, and never reach for a separate_kfvtable. Usemtl_system_items_b_kfvonly when you genuinely need a column_vldoesn't expose AND translation is irrelevant. Item LOVs, item-description columns, main-SQL item joins, and Success SQL must all source the item frommtl_system_items_vl.-- Bad (untranslated description from the base KFF view): select msibk.concatenated_segments id, msibk.concatenated_segments value, msibk.description description from mtl_system_items_b_kfv msibk where msibk.recipe_enabled_flag='Y' -- Good (translated description, same concatenated_segments, one view): select msiv.concatenated_segments id, msiv.concatenated_segments value, msiv.description from mtl_system_items_vl msiv where msiv.recipe_enabled_flag='Y'CRITICAL: Users must always see DISPLAY VALUES, never IDs or codes. Blitz Report and Blitz Upload outputs are user-facing. Whenever output is shown to a user (template XLSM, result XLSM, parameter prompts, LOV dropdowns, error messages), the value must be the human-readable, translated display value — never the underlying internal code or ID.
- Status / Action: Use
xu.status_andxu.action_(display values viaxxen_upload.status_meaning/action_meaning), neverxu.status_code_/xu.action_code_— and never hardcoded'S','V','E','C','U','D'etc. - LOV-bound columns: When the LOV has
id/valuecolumns, display thevalue, not theid. - Lookup codes (FND lookups, e.g.
MTL_EAM_ACTIVITY_TYPE): Translate viaxxen_util.meaning(code, lookup_type, app_id)— never display the rawlookup_code. - Yes/No flags: Use
xxen_util.meaning('Y','YES_NO',0)for "Yes", and blank/null for "No" (so the cell renders empty rather than the literal "No"). Never display raw'Y'/'N'. - Date columns: Let the framework's standard date formatting render them. Where a date must be text (an LOV
valuecolumn), usefnd_date.date_to_displaydate(d); there is noxxen_util.user_date. - Never
to_date()a Date parameter's bind: the engine binds Date parameters as DATE, soto_date(:p,'DD-Mon-YYYY')converts it back through the session'snls_date_format, and a concurrent request'sDD-MON-RRturns 2027 into year 0027. Use the bind as it is; an LOV that passes a date as its id passes text in an explicit format (to_char(d,'YYYY/MM/DD'), converted back with the same mask). - IDs (numeric or rowid): Never expose to users in display columns. Even hidden helper columns that hold IDs should be marked
<HIDDEN>Y</HIDDEN>so users don't see them.-- Bad (hardcoded code, user sees "S"): 'S' status_,
-- Good (display value, user sees "Success" / "Erfolgreich"): xu.status_,
-- Bad (raw lookup code): pcr.dependent_flag,
-- Good (translated display, blank for 'N'): decode(pcr.dependent_flag,'Y',xxen_util.meaning('Y','YES_NO',0)) dependent,
- Status / Action: Use
Use the most restrictive Oracle data-dictionary view possible. Narrower views are faster and convey intent —
user_objectssays "my own schema",all_objects"any object I can see",dba_objects"the whole database, needs SELECT ANY DICTIONARY". The choice follows the OWNER of the object (the EBS custom-development storage split):user_*— objects owned by the schema running the script (appsfor our install/upgrade flow): APPS-owned packages, views, types, synonyms, our customized Oracle bodies (FND_WEBFILE), APPS' own privileges (user_sys_privs,user_tab_privs), the schema's ORDS objects (user_ords_*). Drop redundantowner='APPS'/grantee='APPS'predicates.all_*— objects in another schema visible to APPS: the storage objects (TABLE,INDEX,SEQUENCE, their triggers) of the custom schema (XXENby default, customers may choose another — from APPSuser_tablesreturns nothing for them), JA, APPLSYS, SYS via SELECT ANY DICTIONARY, ECC, ORDS_METADATA.dba_*— only when there is no narrower alternative: DB-wide scope (dba_objects do where do.status<>'VALID'after an install), tablespace metadata (dba_tablespaces—user_tablespacesshows only quota'd ones), foreign admin-user lookups (dba_users du where du.username in ('EBS_SYSTEM','SYSTEM')).-- Bad (XXEN_SCHUB is an APPS-owned package): select 'Y' from dba_objects do where do.object_name='XXEN_SCHUB' and do.object_type='PACKAGE' and rownum=1 -- Good: select 'Y' from user_objects uo where uo.object_name='XXEN_SCHUB' and uo.object_type='PACKAGE' and rownum=1
-- Bad (XXEN_REPORTS lives in the custom schema — user_tables from APPS returns nothing): select ut.tablespace_name from user_tables ut where ut.table_name='XXEN_REPORTS' and rownum=1 -- Good: select at.tablespace_name from all_tables at where at.table_name='XXEN_REPORTS' and rownum=1
-- Bad (checking APPS' own system privilege): select dsp.privilege from dba_sys_privs dsp where dsp.grantee='APPS' and dsp.privilege='SELECT ANY DICTIONARY' -- Good: select usp.privilege from user_sys_privs usp where usp.privilege='SELECT ANY DICTIONARY'
- **Column-availability trap —all_*is notdba_*minus a filter.**all_userslacksdefault_tablespace,temporary_tablespace,created,account_status,profile(they exist only ondba_users/user_users);select au.default_tablespace from all_users auparses on its own but fails at runtime withORA-00904, which inside an embedded cursor cascades intoPLS-00364: loop index variable 'C' use is invalidon every reference. Before tighteningdba_X→all_X,desc all_Xand confirm every selected column exists; foreign admin-user metadata always stays ondba_users.fnd_api.g_miss_*sentinels are NOT null. The Oracle FND API "no-change" sentinels are real values masquerading as missing:fnd_api.g_miss_char=chr(0)fnd_api.g_miss_num=9.99e125fnd_api.g_miss_date='01-JAN-4712'
Never assume
is nullwill detect them. When calling an Oracle API that uses these as "no change" markers, watch for two failure modes:- Wrapping the value yourself:
if l_value is not null then ...skips logic that should run for g_miss; useif l_value <> fnd_api.g_miss_*(with type-matched comparison) ornvl(l_value,sentinel)=sentinelinstead. - API-side
is not nullchecks that misfire on the sentinel: some Oracle public APIs gate work onif p_date is not nulland use g_miss_date as no-change concurrently. Passing g_miss_date trips theis not nullcheck and triggers the work as if you'd supplied a real value. Known case:inv_loc_wms_pub.update_locator(bug 7297065) — seeupload-api-patternsskill § INV Stock Locator Upload for the documented workaround pattern.
Generate rows / split a delimited string with
xxen_util.rowgen(n)— neverconnect by levelortable(cast(multiset(...))).xxen_util.rowgen(p_rows in number) return fnd_table_of_number deterministicreturnsnrows; reference the generated counter asrowgen.column_value. It is the standard Blitz idiom (used throughoutXXEN_REPORT.pkb/XXEN_XDO.pkb) for the "explode a row into N rows" pattern, e.g. extracting each token of a delimited list withregexp_substr(...,1,rowgen.column_value). Count the items with the same pattern you extract — no+1.-- Bad (verbose collection-cast / connect-by row generator): table(cast(multiset(select level from dual connect by level<=regexp_count(c.list,';')+1) as sys.odcinumberlist)) l ... regexp_substr(c.list,'[^;]+',1,l.column_value) -- Good (rowgen — count the token pattern, no +1): table(xxen_util.rowgen(regexp_count(c.list,'[^;]+'))) rowgen ... regexp_substr(c.list,'[^;]+',1,rowgen.column_value)Typical full pattern — split a semicolon-delimited column into one row per token:
select regexp_substr(c.list,'[^;]+',1,rowgen.column_value) token from my_table c, table(xxen_util.rowgen(regexp_count(c.list,'[^;]+'))) rowgen where c.list is not nullFor the "extract the Nth
:bind" use case the codebase counts the bind pattern:table(xxen_util.rowgen(regexp_count(x.sql_text,':\w+'))) rowgen+regexp_substr(x.sql_text,':\w+',1,rowgen.column_value).rowgenisdeterministic, so it costs nothing extra in repeated calls.No redundant "belt-and-braces" defensive code — a guard that cannot fire hides a broken assumption. Never add a condition, filter, null-check, or exclusion that is provably unreachable given the surrounding logic. It is confusing (it implies the impossible can happen), bloats the code, and — exactly like a broad
when others(rule #17) — silently absorbs an inconsistency so you never discover that the code or your initial assumption was wrong. Write the predicate/guard that is exactly necessary and no more, so a wrong assumption fails loudly instead of being papered over.- Before adding a guard "just in case", verify whether the case can actually occur (query/inspect the data). If it cannot, omit the guard. If you are genuinely unsure, surface the uncertainty to the user rather than defending against it silently.
- Worked example: a delete that targets rows carrying marker
Xmust NOT also carryand description not like 'Y%'whenY-rows provably never containX(verify: count ofY-rows that matchXis 0). TheXmarker is already the exact discriminator, so theY-exclusion guards an empty set — and worse, it would silently skip a mislabelledY-row that genuinely containedX, the one case you would actually want surfaced. - Same family as #2 (no redundant nvl), #13 (no code duplication), #17 (no needless exception handling) and #37 (minimalistic code is paramount): keep code concise, clean and exact.
Trunc both sides of date comparisons in validation logic — calendar-day semantics, not instant-in-time. When PL/SQL validation logic compares a date value (column, bind variable, or user-supplied parameter) against
sysdatefor correctness purposes (e.g. "must be ≥ today", "no more than N years in the future", "cannot be in the past"), applytruncto BOTH sides of the comparison. Calendar-day semantics: a "today" check must accept any time during today, not just midnight; a "yesterday" check must reject yesterday regardless of clock time.- The canonical pattern:
trunc(p_date)<trunc(sysdate)for "must be ≥ today" rejection. - An asymmetric form like
p_date<trunc(sysdate)silently lets "yesterday at 11pm" through wheneversysdatehas elapsed past midnight, becausetruncstrips time on one side only — the bind still carries23:00:00, the right side is00:00:00, and the comparison says "false, accept" when it should say "true, reject". - For "N years from today" caps: use
trunc(p_date)>trunc(sysdate)+N*365(calendar-day arithmetic with both sides truncated). - Pairs with — does not conflict with — rule #42 (which forbids
truncon indexed date columns in WHERE clauses for performance). Rule #42 is about SQL queries against base tables (kills index access → full table scan). This rule is about PL/SQL validation logic comparing user-supplied dates tosysdate(correctness — no index involved). The two rules apply in different contexts and have different goals.-- Good (trunc both sides — calendar-day semantics, "today" accepted any time of day): if trunc(p_effective_date)<trunc(sysdate) then message_:='Effective date cannot be in the past.'; raise le_validation_exception; end if; if trunc(p_end_date)>trunc(sysdate)+300*365 then message_:='End date cannot be more than 300 years in the future.'; raise le_validation_exception; end if;
-- Bad (no trunc — time-of-day discriminates within a single calendar day, "today 09:00" rejected at 10:00): if p_effectivedate<sysdate then message:='Effective date cannot be in the past.'; raise le_validation_exception; end if;
-- Bad (asymmetric — lets yesterday-late-evening through): -- p_date holds e.g. yesterday 23:00:00; trunc(sysdate) is today 00:00:00; 23:00<00:00 is false → row accepted as "today" if p_effectivedate<trunc(sysdate) then message:='Effective date cannot be in the past.'; raise le_validation_exception; end if;
- The canonical pattern:
No backward-compatibility shims for superseded formats — migrate, don't dual-support. Code must be minimalistic; avoid technical debt that exists only to keep supporting outdated logic. When a stored format / code scheme / data convention is replaced (e.g. single-char assignment-level codes
S/A/F→ full wordsSite/Application/Form), do a clean cut-over: a one-time data migration converts existing rows, and ALL code (views, parsers, importers, exporters, forms) speaks ONLY the new format. Do NOT add tolerant "accept both old and new" logic (decode(x,'F','Form',x),in ('F','Form')) to keep reading the outdated format at runtime — that debt lingers forever, obscures intent, and silently hides bugs (same family as #13, #17, #37, #50). The ONLY legitimate place the old format may appear is the migration statement itself, which must read the old code once to rewrite it. If you believe a dual-support shim is genuinely needed, surface the specific scenario to the user first — usually there isn't one.Initialize variables in their declaration - When the value is a simple expression available at block entry (e.g. a
fnd_profile.value()call), writel_x type:=<expr>;in the declare section instead of declaring the variable and then assigning it on a separate line later in the begin block. Fewer lines, single source of truth. Pairs with #2 (no redundant nvl).Minimalistic comments in delivered code (PL/SQL, SQL, shell, etc.) - Keep comments to 1-2 short lines that state WHY a non-obvious line exists. Do NOT narrate the full mechanism, restate the code, justify the design at length, enumerate edge cases, or cite external references (Oracle notes, MOS doc IDs, blog URLs) inside shipped code. A multi-line comment block that dwarfs the code it documents is noise. Deep background (root cause, history, supporting evidence) belongs in the commit message or the
.claude/rules/*.mdknowledge files, not in the delivered source. Same minimalism principle as #37 — applies to all delivered code.- NEVER write a comment that describes the CHANGE, the previous behaviour, or a comparison against it. A comment documents the code as it stands now, for a reader who has never seen any earlier version. Change history belongs in the commit message and
git log/git blame; it must not accumulate in the source. This is the single most common comment failure when editing existing code, because the change is what is on the author's mind at the time — and it is exactly what the next reader does not need. - Banned phrasings (each is a rewrite trigger, not a style preference):
previously ...,used to ...,no longer ...,was ... now ...,removed the ...,added ... because,changed from ... to ...,instead of re-querying/re-parsing/looping ...,avoids the extra ...,fixes ...,this was a bug, and any reference to a former implementation, an old parameter, or a deleted line. - The test: delete the comment and ask what a new reader still cannot infer from the code. Write only that. If the answer is "nothing", write no comment.
-- Bad (narrates the change and the code it replaced): p_template_id:=xxen_report.template_id(p_report_id,p_template_name); --resolve through the core instead of re-querying xxen_report_templates l_xml xmltype:=xmltype(p_clob); --parse once, previously every statement re-parsed the full clob regexp_substr(fnd_webfile.get_url(...),'[^=]*$'); --removed the hardcoded oapc/oas parameters, which were captured from one instance
-- Good (states a constraint the code cannot show, or no comment at all): p_template_id:=xxen_report.template_id(p_report_id,p_template_name); l_xml xmltype:=xmltype(p_clob); l_key varchar2(32767):=lower(p_vchr)||'|'||p_col_num||'|'||p_col; --delimited key to avoid collisions between value and column name concatenations
- NEVER write a comment that describes the CHANGE, the previous behaviour, or a comparison against it. A comment documents the code as it stands now, for a reader who has never seen any earlier version. Change history belongs in the commit message and
Every Oracle identifier must be ≤30 bytes — 19c masks a bug that breaks 11g/12.1 (and 11i) installs. Blitz Report ships to EBS 11i, R12.1 and R12.2 — all of which run on a pre-12.2 database (11.2.0.4 / 12.1.0.2) that enforces the classic 30-byte identifier limit. Only Oracle Database 12.2+ (e.g. the 19c development instances) raises the limit to 128 bytes — so a 31-char name compiles and installs fine on 19c dev and then fails on the customer. Same 19c-masks-it trap as the
create_update_person_extra_infonote inupload-api-patternsskill, generalised to all identifiers:- DDL objects (tables, indexes, constraints, sequences, columns, views, triggers, types, synonyms) → over-30 raises
ORA-00972: identifier is too longat create time and the object is silently not created (installers tolerate the error and continue), so a missing unique index/constraint can also let bad data in. - PL/SQL identifiers (package names, and procedure/function names inside a package spec, standalone procedures/functions, parameter names) → over-30 raises
PLS-00114: identifier too long(and cascades asPLS-00201/PLS-00307at call sites). - The generated upload view name
xxen_<prefix>_<hash>_uis bounded (prefix truncated to 18 + 4-char hash), so it is safe — but any hand-named view/table you add is not. - A report or upload SQL COLUMN ALIAS is an identifier too — an over-30 alias raises the same
ORA-00972when the report SQL is parsed, so the report fails for every customer on a pre-12.2 database while running fine on the 19c dev instances. Abbreviate the alias, not the entity prefix, following the existing content (postal_code->post_code); the column heading is derived from the alias, so the rename is user-visible and must be carried into every templateCOLUMN_NAMEand the matching workbookcacheField name. Check withlengthb(column_name)>30againstxxen_report_sql_columnsafter parsing (exclude thexxen_util.dff_columnsoutput, whose names come from the site's own flexfield segments). - Enforced mechanically:
${CLAUDE_PLUGIN_ROOT}/scripts/lint_plsql.pyrule R55 flags any over-30 procedure/function name or DDL object name (pre-commit hook and--all) — a 32-byte procedure name once passed every 19c dev compile and broke the package on a customer's 12.1.0.2 database.
- DDL objects (tables, indexes, constraints, sequences, columns, views, triggers, types, synonyms) → over-30 raises
Never parse SQL text with a greedy
.+regex under thenflag — scan it left to right instead.regexp_substrpatterns chaining several unbounded.+over a whole statement (dot matches newline under'n') backtrack combinatorially AND pick the wrong text:.+latches onto the LASTid/valuealias anywhere in the statement (scalar subqueries, inline views, laterunion allbranches), whereas Oracle names result columns from the FIRST select.xxen_api.value_equal_to_idis the reference scanner.- Symptom: pure CPU inside the regex engine — ASH shows
ON CPUagainst whatever SQL called it and no slow SQL is findable; read the ASHProcedure/Codecolumn instead of hunting for a statement. - Scanner shape: strip comments preserving literals; track string literals, quoted identifiers and bracket depth; split the select list on depth-0 commas up to the depth-0
from; take the trailing identifier as the alias (asoptional, a bare/qualified column is named after itself,"Quoted"honoured); descend into the inline view orwithblock forselect x.* from (..) x. Bracket depth is what keeps a scalar subquery's innerfromfrom ending the select list. - PL/SQL
trim/rtrim/ltrimstrip only SPACES, not newlines or tabs: trim with an explicit set,ltrim(rtrim(s,g_ws),g_ws)whereg_ws:=' '||chr(9)||chr(10)||chr(13)||chr(12). - Package-body constants must precede every subprogram body (
PLS-00103otherwise). - If a regex must stay, gate it behind a cheap linear
regexp_instrfor a literal token it requires (a non-matching regex still pays full backtracking), and\sis NOT whitespace inside a bracket expression — Oracle follows POSIX, so[\s.]matches backslash,sand dot; use[[:space:].]. - Validate a parser against the whole LOV population AND synthetic cases (id-last select lists), comparing captured groups, not just the boolean result.
- Symptom: pure CPU inside the regex engine — ASH shows
Prefer
in (subquery)overexists (correlated subquery)for a plain membership test — it is shorter, needs no correlation predicate and noselect null, and reads as what it is. Same minimalism principle as #37.-- Bad (correlated, longer, repeats the join column on both sides): exists (select null from fnd_conc_pp_actions fcpa where fcr.request_id=fcpa.concurrent_request_id and fcpa.action_type=7) -- Good: fcr.request_id in (select fcpa.concurrent_request_id from fnd_conc_pp_actions fcpa where fcpa.action_type=7)Keep
existsonly whereincannot express the test: a multi-column correlation that is not a simple(a,b) in (...), anot existsanti-join where the subquery column is nullable (not inreturns no rows when the subquery yields a single null — see #43), or a correlation on something other than equality. Both forms give the same plan for a simple membership test, so the choice is readability, not performance. Note this is about a membership predicate, not about replacing a join: where the row must be filtered but not multiplied, a subquery (either form) is still required — a join fans the driving row out once per matching child row.A predicate on an OUTER-JOINED table written without
(+)silently DROPS the driving row — it does not "keep the row and null the column". With(+)syntax only predicates carrying(+)are join conditions; everything else is a post-join filter.a.id=b.id(+) and <filter on b without (+)>therefore behaves like an inner join whenever the driving row HAS matches: rows failing the filter are removed and Oracle does not re-null-extend the driving row, so a driving row disappears when ALL its matched rows fail. Rows with NO match still pass (nullsatisfiesnvl(col,x)=x), which is exactly what hides the bug in testing — it only bites the entities that have child rows.- The shape to watch for is an
nvl(<outer table>.<col>,<sentinel>)=<sentinel>"parent-level child row" filter. It must carry the(+)inside thenvl:-- Bad: an account whose receipt methods are ALL site-level loses its account row completely hca.cust_account_id=rcrm.customer_id(+) and nvl(rcrm.site_use_id,0)=0 and -- Good: stays a join condition, non-matching rows are null-extended hca.cust_account_id=rcrm.customer_id(+) and nvl(rcrm.site_use_id(+),0)=0 and - Audit rule: in any
fromlist with old-style outer joins, every predicate naming an outer-joined alias must either carry(+)or be a deliberate "matched rows only" filter you can justify; sibling child tables joined the same way (profiles / contacts / bank accounts / receipt methods) must all be written the same — an odd one out missing the(+)is a typo. Verify by counting parents with vs without children before and after. - The remedy is not always
(+): a filter on the outer-joined table's OWN column (e.g.nvl(rcrm.primary_flag,'Y')=decode(:p,'Y',nvl(rcrm.primary_flag,'Y'),'Y')) belongs in an inline view applied BEFORE the join —(select /*+ inline */ ... from ra_cust_receipt_methods rcrm where rcrm.primary_flag=decode(:p,'Y',rcrm.primary_flag,'Y')) rcrm— so non-primary rows never enter the join. Audit heuristic: a predicate line that names an outer-joined alias and contains no(+)token at all is a filter, not a join condition. - When backporting such a fix to a customer's older build, check which UNION branch that customer's PARAMETERS actually execute (e.g.
Update Level = Sitenever runs the account branch); the request log'sParameters and bindssection prints the deployed SQL, so confirm the predicates there rather than inferring them.ra_cust_receipt_methodshas noorg_id(keyed oncustomer_id+site_use_idonly), so a site use in ANY operating unit can match. - Related to #23 (join flow) and #50 (no guard that cannot fire): this is the opposite failure — a predicate that fires when it must not.
- The shape to watch for is an
Building a delimited list: derive the separator from the POSITION, never from the accumulator being non-null.
l_list:=l_list||case when l_list is not null then '|' end||<value>is only correct when<value>can never be null: a null FIRST element leaves the accumulator null, the second element is appended with no separator, and the list comes back one element short — every positional consumer is then off by one (a null element in the middle is harmless). Fix:case when c.column_number>1 then '|' end(or a counter) — identical for a non-null first element, so a safe drop-in. The API output's column-data-type row inXXEN_REPORT.pkbis built this way for exactly this reason (a number column with no registered format has a NULLc.format); its sibling accumulators (l_api_column_headers,l_column_width) are safe only because their values cannot be null — do not "fix" them (#50).Never put an XMLTABLE-based subquery inside a set-based DML predicate against a real table — the optimizer re-shreds the XML once per row of the target table. An
in/existssubquery readingxmltable(... passing <clob/xmltype> ...)gets unnested into a semi-join; because the optimizer has no cardinality for XMLTABLE (8168 rows by default, cubed across a three-level shred) it drives from the real table and pushes the predicate into the subquery view, so the entire shred re-executes for every target row. The statement looks set-based and its cost istarget_rows × cost_of_one_full_shred.- Symptom: a
VIEW PUSHED PREDICATE VW_NSO_*(or aFILTER) whose subtree containsXMLTABLE EVALUATION, on the inner side of aNESTED LOOPS SEMIwhose outer side scans the DML target; wildly inflated E-Rows for a tiny XML is the tell. - Fix: drive a cursor loop from the XML ALONE (no table in the cursor) so it is shredded exactly once, and make the DML body a per-iteration statement keyed on an indexed column (one iteration per entity in the XML, so the loop is not the cost).
import_templates' template parameter exclusion insert inXXEN_REPORT.pkbis the reference. Cost scales with the target table AND the xml, so a batch import or a bigger customer is far worse than the dev measurement — same family as #58. - A plain JOIN of xmltables to a table has the same trap: with a nested-loop order driven by the table, the xmltables are re-evaluated per table row (a 34 MB batch file: 0.8 s xml-first, over 10 minutes parameters-first). The dev instances pick the xml-first hash plan, so only a customer shows it. Pin every such statement with
leading(<xml aliases>), asimport_templatesdoes. - Testing caveat for xml round trips:
xxen_api.import_xml(<clob>)does NOT strip CRs, while the real file path (bin/import_xml→ FNDGFU →xxen_report_ebs.import_xml_file_name→import_xml_file) does, andexport_file_dataconverts everychr(10)tochr(13)||chr(10)on the way out — feeding anexport_reportclob straight back intoimport_xmladds onechr(13)per line on each round trip. Round-trip tests go through a file andbin/import_xml.
- Symptom: a
decode(x,0,null,x)returns VARCHAR2, not NUMBER — usenullif(x,0). Oracle typesdecodefrom its FIRST result, and a null first result converts the return value to varchar2, so the "blank out a zero" idiom silently turns a number column into text: the report column parses asvarchar2, Excel writes it as a string, and the user cannot sum or chart it.nullif(x,0)is the exact equivalent (both treat NULL input as NULL) and keeps the native type.- Do NOT "fix" it with
to_number(decode(...))— that round-trips number → varchar2 → number and is NLS-dependent.nullifis also shorter:decode(a-b-c,0,null,a-b-c)→nullif(a-b-c,0). - The type leak compounds in an aggregate:
max(decode(x,0,null,x))is a STRING max ('9' > '10');sum()still works via implicit conversion but is NLS-dependent. - Detect with
regexp_like(sql_text,'decode\(([A-Za-z0-9_.-]+),0,null,\1\)'). - Verify the swap by re-parsing, not by reading the diff: the affected columns must flip
varchar2→numberinxxen_report_sql_columns(the flip itself proves every operand was numeric), and the Excel output must write them as numeric cells (not="s"). Identical on 11.2, so no11itwin is needed.
- Do NOT "fix" it with
xxen_util.clob_replaceloops until nothing matches — never call it when the replacement contains the search text. It repeatsreplace_first_occurrencewhiledbms_lob.instr(result,search)>0, so an insertion that keeps the searched text inside it (<anchor>→<predicate> and <anchor>) re-matches its own output and grows the CLOB until temp space is gone; the session shows one UPDATE onwrite complete waitswith lob reads in the millions. For a single insertion usexxen_util.replace_first_occurrenceafter proving the text occurs once;clob_replaceis for replacements that remove or transform the searched text. The trap is data-dependent (only when the character before the anchor makes the search re-match), so a script that passed hundreds of reports can still hang on one — same family as #56 (parsers validated on the whole population).A PL/SQL function inside a subquery's range or filter predicate is evaluated once per CANDIDATE ROW, not once per subquery execution — wrap it in
(select f(...) from dual). The optimizer cannot prove a non-deterministicfunction is invariant, so a bound such asbetween col and mrp_calendar.date_offset(outer.a,outer.b,outer.c,1)-1lands in the table-access filter and runs for every row the index range returns, although its arguments are constant for that execution. The scalar-subquery form evaluates it once and caches it, for identical output.- The call count is the KEY RANGE's row count, not the result's, whenever the bound column is missing from the index (
mrp_forecast_updates.forecast_update_dateis not inMRP_FORECAST_UPDATES_N1): every row of the key is fetched and filtered. - Signature: ASH shows the top-level statement
ON CPUwith nosql_plan_line_idand there is no slow SQL to find; the cost sits inv$sqlas a recursive cursor belonging to the called package with executions in the hundreds of thousands. Read those recursive cursors'executions, not the plan. - Do NOT add
deterministicto someone else's function to get the same effect; and when counting the saving, remember aselect count(*)wrapper discards the select list, so it measures neither the scalar subqueries nor their function calls.
- The call count is the KEY RANGE's row count, not the result's, whenever the bound column is missing from the index (
Never guess whether an input is an id or a display value - a routine is told which one it receives. Trying
to_number(p_x)as an id and falling back to a lookup by code, testingregexp_like(p_x,'^[0-9]+$'), or accepting'Y'and the translatedYesalike all misfire as soon as a code or name is numeric: a department code5101is taken as department id 5101. Numeric codes are common at customers (departments, lines, organizations, segment values, document numbers), while Vision data is mostly alphabetic, so such code passes every internal test.- Give each routine one input form. When the callers hold different forms, convert in the caller (an upload's server-side backstop resolves its ids to the display values the add-in sends, as
upload_std_opdoes before callingvalidate_std_op_excel_row). - A reusable routine that must accept both takes an explicit indicator of what is passed (
'id'or'value', asvalidate_val'sp_bind_columninXXEN_REPORT) and never infers it from the content. - The rule is about inputs that are processed or validated. A display column may fall back to the raw code when no translation exists (
nvl(xxen_util.meaning(code,...),code)): showing the code is better than a blank.
- Give each routine one input form. When the callers hold different forms, convert in the caller (an upload's server-side backstop resolves its ids to the display values the add-in sends, as
Never give a
result_cachefunction a high-cardinality key (code combination, segment value, row id) or have it read a table that transactions change; cache such results per session in a package collection, as Oracle's flex engine does for value security (fnd_plsql_cacheinFND_FLEX_SERVER). Each result takes at least one 1 KB block of the instance-wide cache, a committed DML on any table the function reads invalidates every result of the function (a newgl_code_combinationsrow flushes all cached code combination results), and once the cache is full every new result scans the LRU chain under theResult Cache: RC Latch, which makes a full cache far slower than no cache. Keepresult_cachefor small, stable key spaces such as setup lookups. Store a session cache entry only after its evaluation succeeds: a default stored first stays cached when the evaluation raises, and for security that means access granted.- Signature: ASH on the report or LOV SQL dominated by
latch free(P2 = theResult Cache: RC Latchnumber inv$latchname) andenq: RC - Result Cache: Contention, or by CPU in the calling PL/SQL frame;DBA Result Cache Statisticsshows Current Size equal to Maximum Size withDelete Count Validrising as fast asCreate Count Success;DBA Result Cache Objects and Dependencieslists the function with hundreds of thousands of results.
- Signature: ASH on the report or LOV SQL dominated by
Test a code for the value you mean, not for the absence of another:
security_enabled_flag='N', notsecurity_enabled_flag<>'Y'. When a column's values are known (aY/Nflag, anI/Eindicator, a value set validation type), compare with the value the condition is about (include_exclude_indicator='E',validation_type in ('D','Y','F')) instead of negating the other one: it reads directly, and both forms exclude null, so they are equivalent whenever the listed values are all the column holds. Keep<>/not infor open-ended values; for an EnginaticsY-or-null flag (#41) the other value isis null.