How secure is format() for dynamic queries inside a function? - json

After reading Postgres manual and many posts here, I wrote this function tacking in mind all I found regarding security. It works great and does everything I looked for.
takes a json, each key has an array [visible, filter, arg1, optional arg2]
SELECT public.goods__list_json2('{"name": [true, 7, "Ad%"], "category": [true], "stock": [false, 4, 0]}', 20, 0);
returns a json array with requested data.
[{"name": "Adventures of TRON", "category": "Atari 2600"}, {"name": "Adventure", "category": "Atari 2600"}]
My question is, how could I really be sure that when I create the query using user input arguments, passing them as %L with format is injection safe?
By my db design, all is done through functions, running most of them as security definer only allowing certain roles to execute them.
Being secure, my intention is to convert old functions to this dynamic logic and save myself to write a lot of lines of code creating new or specific queries.
I would really appreciate a experienced Postgres developer could give me an advice on this.
I'm using Postgres 13.
CREATE FUNCTION public.goods__list_json (IN option__j jsonb, IN limit__i integer, IN offset__i integer)
RETURNS jsonb
LANGUAGE plpgsql
VOLATILE
STRICT
SECURITY DEFINER
COST 1
AS $$
DECLARE
table__v varchar := 'public.goods_full';
column__v varchar[] := ARRAY['id', 'id__category', 'category', 'name', 'barcode', 'price', 'stock', 'sale', 'purchase'];
filter__v varchar[] := ARRAY['<', '>', '<=', '>=', '=', '<>', 'LIKE', 'NOT LIKE', 'ILIKE', 'NOT ILIKE', 'BETWEEN', 'NOT BETWEEN'];
select__v varchar[];
where__v varchar[];
sql__v varchar;
key__v varchar;
format__v varchar;
temp__v varchar;
temp__i integer;
betw__v varchar;
result__j jsonb;
BEGIN
FOR key__v IN SELECT jsonb_object_keys(option__j) LOOP
IF key__v = ANY(column__v) THEN
IF (option__j->key__v->0)::bool THEN
select__v := array_append(select__v, key__v);
END IF;
temp__i := (option__j->key__v->1)::int;
IF temp__i > 0 AND temp__i <= array_length(filter__v, 1) THEN
temp__v := (option__j->key__v->>2)::varchar;
IF temp__i >= 11 THEN
betw__v := (option__j->key__v->>3)::varchar;
format__v := format('%I %s %L AND %L', key__v, filter__v[temp__i], temp__v, betw__v);
ELSE
format__v := format('%I %s %L', key__v, filter__v[temp__i], temp__v);
END IF;
where__v := array_append(where__v, format__v);
END IF;
END IF;
END LOOP;
sql__v := 'SELECT jsonb_agg(t) FROM (SELECT '
|| array_to_string(select__v, ', ')
|| format(' FROM %s WHERE ', table__v)
|| array_to_string(where__v, ' AND ')
|| format(' OFFSET %L LIMIT %L', offset__i, limit__i)
|| ') t';
RAISE NOTICE 'SQL: %', sql__v;
EXECUTE sql__v INTO result__j;
RETURN result__j;
END;
$$;

A word of warning: this style with dynamic SQL in SECURITY DEFINER functions can be elegant and convenient. But don't overuse it. Do not nest multiple levels of functions this way:
The style is much more error prone than plain SQL.
The context switch with SECURITY DEFINER has a price tag.
Dynamic SQL with EXECUTE cannot save and reuse query plans.
No "function inlining".
And I'd rather not use it for big queries on big tables at all. The added sophistication can be a performance barrier. Like: parallelism is disabled for query plans this way.
That said, your function looks good, I see no way for SQL injection. format() is proven good to concatenate and quote values and identifiers for dynamic SQL. On the contrary, you might remove some redundancy to make it cheaper.
Function parameters offset__i and limit__i are integer. SQL injection is impossible through integer numbers, there is really no need to quote them (even though SQL allows quoted string constants for LIMIT and OFFSET). So just:
format(' OFFSET %s LIMIT %s', offset__i, limit__i)
Also, after verifying that each key__v is among your legal column names - and while those are all legal, unquoted column names - there is no need to run it through %I. Can just be %s
I'd rather use text instead of varchar. Not a big deal, but text is the "preferred" string type.
Related:
Format specifier for integer variables in format() for EXECUTE?
Function to return dynamic set of columns for given table
COST 1 seems too low. The manual:
COST execution_cost
A positive number giving the estimated execution cost for the
function, in units of cpu_operator_cost. If the function
returns a set, this is the cost per returned row. If the cost is not
specified, 1 unit is assumed for C-language and internal functions,
and 100 units for functions in all other languages. Larger values
cause the planner to try to avoid evaluating the function more often
than necessary.
Unless you know better, leave COST at its default 100.
Single set-based operation instead of all the looping
The whole looping can be replaced with a single SELECT statement. Should be noticeably faster. Assignments are comparatively expensive in PL/pgSQL. Like this:
CREATE OR REPLACE FUNCTION goods__list_json (_options json, _limit int = NULL, _offset int = NULL, OUT _result jsonb)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER AS
$func$
DECLARE
_tbl CONSTANT text := 'public.goods_full';
_cols CONSTANT text[] := '{id, id__category, category, name, barcode, price, stock, sale, purchase}';
_oper CONSTANT text[] := '{<, >, <=, >=, =, <>, LIKE, "NOT LIKE", ILIKE, "NOT ILIKE", BETWEEN, "NOT BETWEEN"}';
_sql text;
BEGIN
SELECT concat('SELECT jsonb_agg(t) FROM ('
, 'SELECT ' || string_agg(t.col, ', ' ORDER BY ord) FILTER (WHERE t.arr->>0 = 'true')
-- ORDER BY to preserve order of objects in input
, ' FROM ' || _tbl
, ' WHERE ' || string_agg (
CASE WHEN (t.arr->>1)::int BETWEEN 1 AND 10 THEN
format('%s %s %L' , t.col, _oper[(arr->>1)::int], t.arr->>2)
WHEN (t.arr->>1)::int BETWEEN 11 AND 12 THEN
format('%s %s %L AND %L', t.col, _oper[(arr->>1)::int], t.arr->>2, t.arr->>3)
-- ELSE NULL -- = default - or raise exception for illegal operator index?
END
, ' AND ' ORDER BY ord) -- ORDER BY only cosmetic
, ' OFFSET ' || _offset -- SQLi-safe, no quotes required
, ' LIMIT ' || _limit -- SQLi-safe, no quotes required
, ') t'
)
FROM json_each(_options) WITH ORDINALITY t(col, arr, ord)
WHERE t.col = ANY(_cols) -- only allowed column names - or raise exception for illegal column?
INTO _sql;
IF _sql IS NULL THEN
RAISE EXCEPTION 'Invalid input resulted in empty SQL string! Input: %', _options;
END IF;
RAISE NOTICE 'SQL: %', _sql;
EXECUTE _sql INTO _result;
END
$func$;
db<>fiddle here
Shorter, faster and still safe against SQLi.
Quotes are only added where necessary for syntax or to defend against SQL injection. Burns down to filter values only. Column names and operators are verified against the hard-wired list of allowed options.
Input is json instead of jsonb. Order of objects is preserved in json, so you can determine the sequence of columns in the SELECT list (which is meaningful) and WHERE conditions (which is purely cosmetic). The function observes both now.
Output _result is still jsonb. Using an OUT parameter instead of the variable. That's totally optional, just for convenience. (No explicit RETURN statement required.)
Note the strategic use of concat() to silently ignore NULL and the concatenation operator || so that NULL makes the concatenated string NULL. This way, FROM, WHERE, LIMIT, and OFFSET are only inserted where needed. A SELECT statement works without either of those. An empty SELECT list (also legal, but I suppose unwanted) results in a syntax error. All intended.
Using format() only for WHERE filters, for convenience and to quote values. See:
String concatenation using operator "||" or format() function
The function isn't STRICT anymore. _limit and _offset have default value NULL, so only the first parameter _options is required. _limit and _offset can be NULL or omitted, then each is stripped from the statement.
Using text instead of varchar.
Made constant variables actually CONSTANT (mostly for documentation).
Other than that the function does what your original does.

I tried to put all that I learned here and I came up with this below and new questions =D.
Is there any advantage declaring _oper this way '{LIKE, "NOT LIKE"}' instead of ARRAY['LIKE', 'NOT LIKE']?
Casting as int _limit and _offset, I'm assuming no SQLi, right?
Is it an elegant way for 'IN' and 'NOT IN' CASE? I wonder why string_agg() is allowed nested in concat() but not there where I needed to use a sub query.
This is a naive private function.
Edit: Removed "SECURITY DEFINER" as identified dangerous.
CREATE FUNCTION public.crud__select (IN _tbl text, IN _cols text[], IN _opts json, OUT _data jsonb)
LANGUAGE plpgsql STRICT AS
$$
DECLARE
_oper CONSTANT text[] := '{<, >, <=, >=, =, <>, LIKE, "NOT LIKE", ILIKE, "NOT ILIKE", BETWEEN, "NOT BETWEEN", IN, "NOT IN"}';
BEGIN
EXECUTE (
SELECT concat('SELECT jsonb_agg(t) FROM ('
, 'SELECT ' || string_agg(e.col, ', ' ORDER BY ord) FILTER (WHERE e.arr->>0 = 'true')
, ' FROM ', _tbl
, ' WHERE ' || string_agg(
CASE
WHEN (e.arr->>1)::int BETWEEN 1 AND 10 THEN
format('%s %s %L', e.col, _oper[(e.arr->>1)::int], e.arr->>2)
WHEN (e.arr->>1)::int BETWEEN 11 AND 12 THEN
format('%s %s %L AND %L', e.col, _oper[(e.arr->>1)::int], e.arr->>2, e.arr->>3)
WHEN (e.arr->>1)::int BETWEEN 13 AND 14 THEN
format('%s %s (%s)', e.col, _oper[(e.arr->>1)::int], (
SELECT string_agg(format('%L', ee), ',') FROM json_array_elements_text(e.arr->2) ee)
)
END, ' AND ')
, ' OFFSET ' || (_opts->>'_offset')::int
, ' LIMIT ' || (_opts->>'_limit')::int
, ') t'
)
FROM json_each(_opts) WITH ORDINALITY e(col, arr, ord)
WHERE e.col = ANY(_cols)
) INTO _data;
END;
$$;
Then for table or view, I create wrapper function executable for some roles.
CREATE FUNCTION public.goods__select (IN _opts json, OUT _data jsonb)
LANGUAGE sql STRICT SECURITY DEFINER AS
$$
SELECT public.crud__select(
'public.goods_full',
ARRAY['id', 'id__category', 'category', 'name', 'barcode', 'price', 'stock', 'sale', 'purchase'],
_opts
);
$$;
SELECT public.goods__select('{"_limit": 10, "name": [true, 9, "a%"], "id__category": [true, 13, [1, 2]], "category": [true]}'::json);
[{"name": "Atlantis II", "category": "Atari 2600", "id__category": 1}, .. , {"name": "Amidar", "category": "Atari 2600", "id__category": 1}]

Related

Teradata Masking - Retain all chararcters at position 1,4,8,12,16 .... in a string and mask remaining characters with 'X'

I have a requirement where I need to mask all but characters in position 1,4,8,12,16.. for a variable length string with 'X'
For example:
Input string - 'John Doe'
Output String - 'JXXn xxE'
SPACE between the two strings must be retained.
Kindly help or reach out for more details if required.
I think maybe an external function would be best here, but if that's too much to bite off, you can get crafty with strtok_split_to_table, xml_agg and regexp_replace to rip the string apart, replace out characters using your criteria, and stitch it back together:
WITH cte AS (SELECT REGEXP_REPLACE('this is a test of this functionality', '(.)', '\1,') AS fullname FROM Sys_Calendar.calendar WHERE calendar_date = CURRENT_DATE)
SELECT
REGEXP_REPLACE(REGEXP_REPLACE((XMLAGG(tokenout ORDER BY tokennum) (VARCHAR(200))), '(.) (.)', '\1\2') , '(.) (.)', '\1\2')
FROM
(
SELECT
tokennum,
outkey,
CASE WHEN tokennum = 1 OR tokennum mod 4 = 0 OR token = ' ' THEN token ELSE 'X' END AS tokenout
FROM TABLE (strtok_split_to_table(cte.fullname, cte.fullname, ',')
RETURNS (outkey VARCHAR(200), tokennum integer, token VARCHAR(200) CHARACTER SET UNICODE)) AS d
) stringshred
GROUP BY outkey
This won't be fast on a large data set, but it might suffice depending on how much data you have to process.
Breaking this down:
WITH cte AS (SELECT REGEXP_REPLACE('this is a test of this functionality', '(.)', '\1,') AS fullname FROM Sys_Calendar.calendar WHERE calendar_date = CURRENT_DATE)
This CTE is just adding a comma between every character of our incoming string using that regexp_replace function. Your name will come out like J,o,h,n, ,D,o,e. You can ignore the sys_calendar part, I just put that in so it would spit out exactly 1 record for testing.
SELECT
tokennum,
outkey,
CASE WHEN tokennum = 1 OR tokennum mod 4 = 0 OR token = ' ' THEN token ELSE 'X' END AS tokenout
FROM TABLE (strtok_split_to_table(cte.fullname, cte.fullname, ',')
RETURNS (outkey VARCHAR(200), tokennum integer, token VARCHAR(200) CHARACTER SET UNICODE)) AS d
This subquery is the important bit. Here we create a record for every character in your incoming name. strtok_split_to_table is doing the work here splitting that incoming name by comma (which we added in the CTE)
The Case statement just runs your criteria swapping out 'X' in the correct positions (record 1, or a multiple of 4, and not a space).
SELECT
REGEXP_REPLACE(REGEXP_REPLACE((XMLAGG(tokenout ORDER BY tokennum) (VARCHAR(200))), '(.) (.)', '\1\2') , '(.) (.)', '\1\2')
Finally we use XMLAGG to combine the many records back into one string in a single record. Because XMLAGG adds a space in between each character we have to hit it a couple of times with regexp_replace to flip those spaces back to nothing.
So... it's ugly, but it does the job.
The code above spits out:
tXXs XX X XeXX oX XhXX fXXXtXXXaXXXy
I couldn't think of a solution, but then #JNevill inspired me with his idea to add a comma to each character :-)
SELECT
RegExp_Replace(
RegExp_Replace(
RegExp_Replace(inputString, '(.)(.)?(.)?(.)?', '(\1(\2[\3(\4', 2)
,'(\([^ ])', 'X')
,'(\(|\[)')
,'this is a test of this functionality' AS inputString
tXXs XX X XeXX oX XhXX fXXXtXXXaXXXy
The 1st RegExp_Replace starts at the 2nd character (keep the 1st character as-is) and processes groups of (up to) 4 characters adding either a ( (characters #1,#2,#4, to be replaced by X unless it's a space) or [ (character #3, no replacement), which results in :
t(h(i[s( (i(s[ (a( (t[e(s(t( [o(f( (t[h(i(s( [f(u(n(c[t(i(o(n[a(l(i(t[y(
Of course this assumes that both characters don't exists in your input data, otherwise you have to choose different ones.
The 2nd RegExp_Replace replaces the ( and the following character with X unless it's a space, which results in:
tXX[s( XX[ X( X[eXX( [oX( X[hXX( [fXXX[tXXX[aXXX[y(
Now there are some (& [ left which are removed by the 3rd RegExp_Replace.
As I still consider me as a beginner in Regular Expressions, there will be better solutions :-)
Edit:
In older Teradata versions not all parameters were optional, then you might have to add values for those:
RegExp_Replace(
RegExp_Replace(
RegExp_Replace(inputString, '(.)(.)?(.)?(.)?', '(\1(\2[\3(\4', 2, 0 'c')
,'(\([^ ])', 'X', 1, 0 'c')
,'(\(|\[)', '', 1, 0 'c')

Using ltrim and rtrim in combination of substring

Hey there so I'm in the process of another project conversion there is a line written in Oracle SQL and I'm trying to convert it to MS SQL:
Oracle PL/SQL:
IF LTRIM(sCmtStr) IS NOT NULL THEN
sTrimStr := ' '||SUBSTR(RTRIM(LTRIM(sCmtStr),'; '),1,999);
ELSE
sTrimStr := NULL;
MS T-SQL:
IF ltrim(#sCmtStr) IS NOT NULL
SET #sTrimStr = ' ' + ISNULL(substring(rtrim(ltrim(#sCmtStr), '; '), 1, 999), '')
ELSE
SET #sTrimStr = NULL
I get the following error:
Msg 174, Level 15, State 1, Procedure TRIMCOMMENT, Line 12
The rtrim function requires 1 argument(s).
Any idea? Thank you.
RTRIM (https://learn.microsoft.com/en-us/sql/t-sql/functions/rtrim-transact-sql) Does not take 2 arguments; it can only trim spaces from the end of a string.
TRIM (https://learn.microsoft.com/en-us/sql/t-sql/functions/trim-transact-sql), on the other hand, can be given other characters to remove. It will remove the characters from both ends, however, so you might have to fiddle a bit if you only want it to remove from the end of the string.

SSIS Substring Extract based on qualifier

I've looked through a few different post trying to find a solution for this. I have a column that contains descriptions that follow the following format:
String<Numeric>
However the column isn't limited to one set of the previous mentioned format it could be something like
UNI<01> JPG<84>
JPG<84> UNI<01>
JPG<84>
UNI<01>
And other variations without any controlled pattern.
What I am needing to do is extract the number between <> into a separate column in another table based on the string before the <>. So UNI would qualify the following numeric to go to a certain table.column, while JPG would qualify to another table etc. I have seen functions to extract the numeric but not qualifying and only pulling the numeric if it is prefaced with a given qualifier string.
Based on the scope limitation mentioned in the question's comments that only one type of token (Foo, Bar, Blat, etc.) needs to be found at a time: you could use an expression in a Derived Column to find the token of interest and then extract the value between the arrows.
For example:
FINDSTRING([InputColumn], #[User::SearchToken] + "<", 1) == 0)?
NULL(DT_WSTR, 1) :
SUBSTRING([InputColumn],
FINDSTRING([InputColumn], #[User::SearchToken] + "<", 1)
+ LEN(#[User::SearchToken]) + 1,
FINDSTRING(
SUBSTRING([InputColumn],
FINDSTRING([InputColumn], #[User::SearchToken] + "<", 1)
+ LEN(#[User::SearchToken]) + 1,
LEN([InputColumn])
), ">", 1) - 1
)
First, the expression checks whether the token specified in #[User::SearchToken] is used in the current row. If it is, SUBSTRING is used to output the value between the arrows. If not, NULL is returned.
The assumption is made that no token's name will end with text matching the name of another token. Searching for token Bar will match Bar<123> and FooBar<123>. Accommodating Bar and FooBar as distinct tokens is possible but the requisite expression will be much more complex.
You could use an asynchronous Script Component that outputs a row with type and value columns for each type<value> token contained in the input string. Pass the output of this component through a Conditional Split to direct each type to the correct destination (e.g. table).
Pro: This approach gives you the option of using one data flow to process all tag types simultaneously vs. requiring one data flow per tag type.
Con: A Script Component is involved, which it sounds like you'd prefer to avoid.
Sample Script Component Code
private readonly string pattern = #"(?<type>\w+)<(?<value>\d+)>";
public override void Input0_ProcessInputRow(Input0Buffer Row)
{
foreach (Match match in Regex.Matches(Row.Data, pattern, RegexOptions.ExplicitCapture))
{
Output0Buffer.AddRow();
Output0Buffer.Type = match.Groups["type"].Value;
Output0Buffer.Value = match.Groups["value"].Value;
}
}
Note: Script Component will need an output created with two columns (perhaps named Type and Value) and then have the output's SynchronousInputID property set to None).
I ended up writing a CTE for a view to handle the data manipulation and then handled the joins and other data pieces in the SSIS package.
;WITH RCTE (Status_Code, lft, rgt, idx)
AS ( SELECT a.Status_code
,LEFT(a.Description, CASE WHEN CHARINDEX(' ', a.Description)=0 THEN LEN(a.Description) ELSE CHARINDEX(' ', a.Description)-1 END)
,SUBSTRING(a.Description, CASE WHEN CHARINDEX(' ', a.Description)=0 THEN LEN(a.Description) ELSE CHARINDEX(' ', a.Description)-1 END + 1, DATALENGTH(a.Description))
,0
FROM [disp] a WHERE NOT( Description IS NULL OR Description ='')
UNION ALL
SELECT r.Status_Code
,CASE WHEN CHARINDEX(' ', r.rgt) = 0 THEN r.rgt ELSE LEFT(r.rgt, CHARINDEX(' ', r.rgt) - 1) END
,CASE WHEN CHARINDEX(' ', r.rgt) > 0 THEN SUBSTRING(r.rgt, CHARINDEX(' ', r.rgt) + 1, DATALENGTH(r.rgt)) ELSE '' END
,idx + 1
FROM RCTE r
WHERE DATALENGTH(r.rgt) > 0
)
SELECT Status_Code
-- ,lft,rgt -- Uncomment to see whats going on
,SUBSTRING(lft,0, CHARINDEX('<',lft)) AS [Description]
,CASE WHEN ISNUMERIC(SUBSTRING(lft, CHARINDEX('<',lft)+1, LEN(lft)-CHARINDEX('<',lft)-1)) >0
THEN CAST (SUBSTRING(lft, CHARINDEX('<',lft)+1, LEN(lft)-CHARINDEX('<',lft)-1) AS INT) ELSE NULL END as Value
FROM RCTE
where lft <> ''

PostgreSQL:Error while calling a custom function

Following is a Function created in PostgreSQL 9.2 ,
CREATE OR REPLACE FUNCTION getRows()
RETURNS TABLE (
"Name" character varying
,"Address" text
,"Phone" text
,"Email" character varying
,"Tin" character varying
,"DL.No1, DL.No2" text
,"Area" character varying
) AS
$func$
BEGIN
select acname as "Name", coalesce(nullif(concat_ws(', ', nullif(add1, ''),
nullif(add2, '')), ''), 'NIL') "Address", coalesce(nullif(concat_ws(', ',
nullif(phoneoff, ''), nullif(mobile, '')), ''), 'NIL') "Phone",case when email
<>'' then email else 'NIL' end as "Email",case when email <>'' then tin else
'NIL' end as "Tin",case when dlno1 ||', '||dlno2=', ' then 'NIL' else dlno1 ||',
'||dlno2 end as "DL.No1, DL.No2",areaname as "Area" from gtab12 left join
gtab47 using(areaid) where acgrcode = '204' order by areaname,"Name";
END
$func$ LANGUAGE plpgsql;
When select getRows() an error is occurring
ERROR: query has no destination for result data
HINT: If you want to discard the results of a SELECT, use PERFORM instead.
CONTEXT: PL/pgSQL function getRows() line 4 at SQL statement
given below is actually what the select query inside the function getRow returns.
You cannot use a SELECT statement in a plpgsql function without the INTO clause (you must use PERFORM instead).
You also forget to RETURN with something.
But, if your function is that simple, you could use a plain sql function (or even a view) instead.
To return a set of something, you need to use RETURN NEXT (to return a row at a time) or RETURN QUERY (to return a number of rows resulting from a query).
See the manual section relating to returning values.
Your example function would need to have:
RETURN QUERY SELECT....;
RETURN;
Note the need for the second RETURN - this is needed because there may be multiple RETURN QUERY statements before the function exits.

SQL: search/replace but only the first time a value appears in record

I have html content in the post_content column.
I want to search and replace A with B but only the first time A appears in the record as it may appear more than once.
The below query would obviously replace all instances of A with B
UPDATE wp_posts SET post_content = REPLACE (post_content, 'A', 'B');
This should actually be what you want in MySQL:
UPDATE wp_post
SET post_content = CONCAT(REPLACE(LEFT(post_content, INSTR(post_content, 'A')), 'A', 'B'), SUBSTRING(post_content, INSTR(post_content, 'A') + 1));
It's slightly more complicated than my earlier answer - You need to find the first instance of the 'A' (using the INSTR function), then use LEFT in combination with REPLACE to replace just that instance, than use SUBSTRING and INSTR to find that same 'A' you're replacing and CONCAT it with the previous string.
See my test below:
SET #string = 'this is A string with A replace and An Answer';
SELECT #string as actual_string
, CONCAT(REPLACE(LEFT(#string, INSTR(#string, 'A')), 'A', 'B'), SUBSTRING(#string, INSTR(#string, 'A') + 1)) as new_string;
Produces:
actual_string new_string
--------------------------------------------- ---------------------------------------------
this is A string with A replace and An Answer this is B string with A replace and An Answer
Alternatively, you could use the functions LOCATE(), INSERT() and CHAR_LENGTH() like this:
INSERT(originalvalue, LOCATE('A', originalvalue), CHAR_LENGTH('A'), 'B')
Full query:
UPDATE wp_posts
SET post_content = INSERT(originalvalue, LOCATE('A', originalvalue), CHAR_LENGTH('A'), 'B');
With reference to https://dba.stackexchange.com/a/43919/200937 here is another solution:
UPDATE wp_posts
SET post_content = CONCAT( LEFT(post_content , INSTR(post_content , 'A') -1),
'B',
SUBSTRING(post_content, INSTR(post_content , 'A') +1))
WHERE INSTR(post_content , 'A') > 0;
If you have another string, e.g. testing then you need to change the +1 above to the according string length. We can use LENGTH() for this purpose. By the way, leave the -1 untouched.
Example: Replace "testing" with "whatever":
UPDATE wp_posts
SET post_content = CONCAT( LEFT(post_content , INSTR(post_content , 'testing') -1),
'whatever',
SUBSTRING(post_content, INSTR(post_content , 'testing') + LENGTH("testing"))
WHERE INSTR(post_content , 'testing') > 0;
By the way, helpful to see how many rows will be effected:
SELECT COUNT(*)
FROM post_content
WHERE INSTR(post_content, 'A') > 0;
If you are using an Oracle DB, you should be able to write something like :
UPDATE wp_posts SET post_content = regexp_replace(post_content,'A','B',1,1)
See here for more informations : http://docs.oracle.com/cd/B19306_01/server.102/b14200/functions130.htm
Note : you really should take care of post_content regarding security issue since it seems to be an user input.
Greg Reda's solution did not work for me on strings longer than 1 character because of how the REPLACE() was written (only replacing the first character of the string to be replaced). Here is a solution that I believe is more complete and covers every use case of the problem when defined as How do I replace the first occurrence of "String A" with "String B" in "String C"?
CONCAT(LEFT(buycraft, INSTR(buycraft, 'blah') - 1), '', SUBSTRING(buycraft FROM INSTR(buycraft, 'blah') + CHAR_LENGTH('blah')))
This assumes that you are sure that the entry ALREADY CONTAINS THE STRING TO BE REPLACED! If you try replacing 'dog' with 'cat' in the string 'pupper', it will give you 'per', which is not what you want. Here is a query that handles that by first checking to see if the string to be replaced exists in the full string:
IF(INSTR(buycraft, 'blah') <> 0, CONCAT(LEFT(buycraft, INSTR(buycraft, 'blah') - 1), '', SUBSTRING(buycraft FROM INSTR(buycraft, 'blah') + CHAR_LENGTH('blah'))), buycraft)
The specific use case here is replacing the first instance of 'blah' inside column 'buycraft' with an empty string ''. I think a pretty intuitive and natural solution:
Find the index of the first occurrence of the string that is to be replaced.
Get everything to the left of that, not including the index itself (thus '-1').
Concatenate that with whatever you are replacing the original string with.
Calculate the ending index of the part of the string that is being replaced. This is easily done by finding the index of the first occurrence again, and adding the length of the replaced string. This will give you the index of the first char after the original string
Concatenate the substring starting at the ending index of the string
An example walkthrough of replacing "pupper" in "lil_puppers_yay" with 'dog':
Index of 'pupper' is 5.
Get left of 5-1 = 4. So indexes 1-4, which is 'lil_'
Concatenate 'dog' for 'lil_dog'
Calculate the ending index. Start index is 5, and 5 + length of 'pupper' = 11. Note that index 11 refers to 's'.
Concatenate the substring starting at the ending index, which is 's_yay', to get 'lil_dogs_yay'.
All done!
Note: SQL has 1-indexed strings (as an SQL beginner, I didn't know this before I figured this problem out). Also, SQL LEFT and SUBSTRING seem to work with invalid indexes the ideal way (adjusting it to either the beginning or end of the string), which is super convenient for a beginner SQLer like me :P
Another Note: I'm a total beginner at SQL and this is pretty much the hardest query I've ever written, so there may be some inefficiencies. It gets the job done accurately though.
I made the following little function and got it:
CREATE DEFINER=`virtueyes_adm1`#`%` FUNCTION `replace_first`(
`p_text` TEXT,
`p_old_text` TEXT,
`p_new_text` TEXT
)
RETURNS text CHARSET latin1
LANGUAGE SQL
NOT DETERMINISTIC
CONTAINS SQL
SQL SECURITY DEFINER
COMMENT 'troca a primeira ocorrencia apenas no texto'
BEGIN
SET #str = p_text;
SET #STR2 = p_old_text;
SET #STR3 = p_new_text;
SET #retorno = '';
SELECT CONCAT(SUBSTRING(#STR, 1 , (INSTR(#STR, #STR2)-1 ))
,#str3
,SUBSTRING(#STR, (INSTR(#str, #str2)-1 )+LENGTH(#str2)+1 , LENGTH(#STR)))
INTO #retorno;
RETURN #retorno;
END
Years have passed since this question was asked, and MySQL 8 has introduced REGEX_REPLACE:
REGEXP_REPLACE(expr, pat, repl[, pos[, occurrence[, match_type]]])
Replaces occurrences in the string expr that match the regular
expression specified by the pattern pat with the replacement string
repl, and returns the resulting string. If expr, pat, or repl is NULL,
the return value is NULL.
REGEXP_REPLACE() takes these optional arguments:
pos: The position in expr at which to start the search. If omitted, the default is 1.
occurrence: Which occurrence of a match to replace. If omitted, the default is 0 (which means “replace all occurrences”).
match_type: A string that specifies how to perform matching. The meaning is as described for REGEXP_LIKE().
So, assuming you can use regular expressions in your case:
UPDATE wp_posts SET post_content = REGEXP_REPLACE (post_content, 'A', 'B', 1, 1);
Unfortunately for those of us on MariaDB, its REGEXP_REPLACE flavor is missing the occurrence parameter. Here's a regex-aware version of Andriy M's solution, conveniently stored as a reusable function as suggested by Luciano Seibel:
DELIMITER //
DROP FUNCTION IF EXISTS replace_first //
CREATE FUNCTION `replace_first`(
`i` TEXT,
`s` TEXT,
`r` TEXT
)
RETURNS text CHARSET utf8mb4
BEGIN
SELECT REGEXP_INSTR(i, s) INTO #pos;
IF #pos = 0 THEN RETURN i; END IF;
RETURN INSERT(i, #pos, CHAR_LENGTH(REGEXP_SUBSTR(i, s)), r);
END;
//
DELIMITER ;
It's simpler
UPDATE table_name SET column_name = CONCAT('A',SUBSTRING(column_name, INSTR(column_name, 'B') + LENGTH('A')));
For MYSQL version pre-5.6 and 8.0, I've used this pattern to fix my issue, it's a bit gross, but I hope it helps some of you guys:
SET #string = 'I love shop it is a terrific shop, I love eveything about it';
SET #shop_code = 'shop';
SET #shop_date = CONCAT(#shop_code, '__', DATE_FORMAT(NOW(), '%Y_%m_%d__%Hh%im%ss'));
SET #part1 = SUBSTRING_INDEX(#string, #shop_code, 1);
SET #shop_nb = ROUND( (LENGTH(#string) - LENGTH(REPLACE(#string, #shop_code,''))) / LENGTH(#shop_code) );
SET #part2 = SUBSTRING_INDEX(#string, #shop_code, -#shop_nb);
SET #string = CONCAT(#part1, #shop_date, #part2);
SELECT #string;
To keep the sample of gjreda a bit more simple use this:
UPDATE wp_post
SET post_content =
CONCAT(
REPLACE(LEFT(post_content, 1), 'A', 'B'),
SUBSTRING(post_content, 2)
)
WHERE post_content LIKE 'A%';