5

Oracle's PL/SQL is fairly new to me, so I need some help understanding if the way I'm trying to use parameters in a Merge's Using clause is even possible.

I'm working with Oracle 11g using ODP.NET for communication with an existing C# .NET 4.0 code-base that uses the SQL connection to retrieve/modify data. The existing SQL statement looks like so:

MERGE INTO Worker Target
USING
(
  SELECT
        :Id0            Id
       ,:Options0       Options
  FROM dual
  UNION ALL
  SELECT
        :Id1            Id
       ,:Options1       Options
  FROM dual
) Source
ON (Target.Id = Source.Id)
WHEN MATCHED THEN
  UPDATE SET
        Target.StateId = :StateId
       ,Target.Options = Source.Options

The Using clause is generated in a C# StringBuilder to accomodate a varying number of worker Id/Option pairs, while at the same time the matching parameters are created.

StringBuilder usingClause = new StringBuilder();
List<OracleParameter> parameters = new List<OracleParameter>();
for (int i = 0; i < workers.Count; ++i)
{
  if (i > 0)
    usingClause.Append("UNION ALL\n");
  usingClause.AppendFormat("SELECT\n   :Id{0}  Id\n  ,:Options{0}  Options\n FROM dual\n", i);

  parameters.Add(new OracleParameter("Id" + i, workers[i].Id));
  parameters.Add(new OracleParameter("Options" + i, workers[i].Options))
}
parameters.Add(new OracleParameter("StateId", pendingStateId));

The usingClause StringBuilder is combined with the rest of the Merge command into a string called 'sql', which is then used in an OracleCommand object. The C# to execute the SQL Merge statement looks like so:

OracleConnection cn = new OracleConnection(
  ConfigurationManager.ConnectionStrings["OracleSystemConnection"].ConnectionString
);

using (OracleCommand cmd = new OracleCommand(sql, cn))
{
  cmd.BindByName = true;
  cn.Open();
  foreach (OracleParameter prm in parameters)
    cmd.Parameters.Add(prm);

  cmd.ExecuteNonQuery();
  cn.Close();
}

I've tried it both with and without binding the parameters by name, and ensuring the order is correct when binding without the parameters by name. What I keep getting is an "ORA-01008: not all variables bound" error.

I've also tried running the Merge command in SQL Developer, and get a response of "Bind Variable 'Id0' is NOT DECLARED." Usually when I run a command in SQL Developer with undeclared bind variables it opens a dialog to enter the values, but not with this SQL command, so it's understandable that it would be undeclared in SQL Developer, but I don't understand why this is the case with the ODP.NET/C# implementation, since I'm adding the parameters to the OracleCommand object.

If anyone could point out what I'm doing wrong, or tell me how to achieve the same effect, it would be greatly appreciated. Also, if anyone knows of a better way to pass a list of values into a Merge's Using clause, than doing a bunch of SELECTs FROM dual with UNION ALLs between them, it would be appreciated as well.

Answer Using Long Raw for the Options Column

After a bit of work, this was the final solution. Thanks to tomi44g for pointing me in the right direction.

DECLARE
  TYPE id_array IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
  TYPE option_array IS TABLE OF LONG RAW INDEX BY PLS_INTEGER;

  t_ids    id_array := :ids;
  t_options    option_array := :options;
BEGIN
  FORALL i IN 1..t.ids.count
    EXECUTE IMMEDIATE '
      MERGE INTO Worker Target
      USING (SELECT :1 Id, :2 Options FROM dual) Source
      ON (Source.Id = Target.Id)
      WHEN MATCHED THEN
      UPDATE SET
         Target.StateId = :3
        ,Target.Options = Source.Options' USING t_ids(i), t_options(i), :state_id;
END;

And this is what the C# changed to, to accomodate the solution.

// Gather the values into arrays for binding.
int[] workerIds = new int[workers.Count];
byte[][] workerOptions = new byte[workers.Count][];
BinaryFormatter binaryFormatter = new BinaryFormatter();
for (int i = 0; i < workers.Count; ++i)
{
    workerIds[i] = workers[i].Id;

    // There's an assumed limit of 4096 bytes here; this is just for testing
    MemoryStream memoryStream = new MemoryStream(4096);
    binaryFormatter.Serialize(memoryStream, workers[i].Options);
    workerOptions[i] = memoryStream.ToArray();
}


// Excute the command.
OracleConnection cn = new OracleConnection(
    ConfigurationManager.ConnectionStrings["OracleSystemConnection"].ConnectionString
);
using (OracleCommand cmd = new OracleCommand(sql, cn))
{
    cmd.BindByName = true;
    cn.Open();

    OracleParameter ids = new OracleParameter();
    ids.OracleDbType = OracleDbType.Int32;
    ids.CollectionType = OracleCollectionType.PLSQLAssociativeArray;
    ids.Value = workerIds;
    ids.ParameterName = "ids";

    OracleParameter options = new OracleParameter();
    options.OracleDbType = OracleDbType.LongRaw;
    options.CollectionType = OracleCollectionType.PLSQLAssociativeArray;
    options.Value = workerOptions;
    options.ParameterName = "options";

    cmd.Parameters.Add(ids);
    cmd.Parameters.Add(options);
    cmd.Parameters.Add(new OracleParameter("state_id", pendingStateId));

    try
    {
        cmd.ExecuteNonQuery();
    }
    catch (OracleException e)
    {
        foreach (OracleError err in e.Errors)
        {
            Console.WriteLine("Message:\n{0}\nSource:\n{1}\n", err.Message, err.Source);
            System.Diagnostics.Debug.WriteLine("Message:\n{0}\nSource:\n{1}\n", err.Message, err.Source);
        }
    }
    cn.Close();
}
Paul
  • 387
  • 1
  • 6
  • 11
  • Can you post the SQL you tried to run in SQL Developer? – moleboy Jun 20 '12 at 14:37
  • I used the same SQL as listed above. Normally SQL Developer will prompt me for the values of the bind variables, but not with the SQL listed there, which I find very odd; it also makes me think I'm doing something wrong, I just don't know what. – Paul Jun 20 '12 at 17:15

1 Answers1

4

You would be better off binding the list of ids and options to arrays and then execute MERGE using FORALL in PL/SQL block:

DECLARE
  TYPE id_array_type IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
  TYPE options_array_type IS TABLE OF VARCHAR2 (100) INDEX BY PLS_INTEGER;

  t_ids        id_array_type := :ids;
  t_options    options_array_type  := :options;
  v_state_id   NUMBER := :stateId;
BEGIN
  FORALL i IN 1 .. t_ids.count
    EXECUTE IMMEDIATE '
      MERGE INTO worker target
      USING (SELECT :id id, :options options FROM dual) source
      ON (source.id = target.id)
      WHEN MATCHED THEN UPDATE SET target.stateId = :state_id, target.options = source.options'
      USING t_ids (i), t_options (i), v_state_id;
END;

Then you can bind the parameter as PL/SQL Associative Array Doing this you will have always one SQL statement in SGA rather than many statements for all possible number of parameters and (which is probably more important) you will be able to merge 1000s of elements in one go.

Actually, I noticed that you are not using the WHEN NOT MATCHED clause. If you are genuinely not interested in inserting new records then there is no need to use MERGE at all, use simply UPDATE instead. You can efficiently execute an UPDATE statement multiple times in one round trip using Array Binding.

tomi44g
  • 3,266
  • 1
  • 20
  • 28
  • I read the links (very informative btw), and I've been trying to implement your suggestion, but it's been giving me an ORA-01036 issue for a few hours. I have two thoughts on what it might be, but don't know the right way to solve either, if they are issues. The first is that :state_id parameter/bind variable may not be passing from the SQL to the PL/SQL inside the EXECUTE IMMEDIATE, though I am adding it to the cmd.Parameters in C#. The second is that Oracle doesn't know what :id and :option are, since they are supposed to come from the arrays, but aren't named. Thoughts? – Paul Jun 20 '12 at 23:36
  • I believe I got past the issue by passing the :state_id bind variable to the EXECUTE IMMEDIATE as the last parameter of its USING statement (i.e. EXECUTE IMMEDIATE '...' USING t_ids(i), t_options(i), :state_id;). However I'm now getting ORA-06550, PLS-00215, PLS-00382 and PLS-00320 errors. To see all of those I had to catch the OracleException, when calling cmd.ExecuteNonQuery(), and iterate over the OracleErrorCollection contained within. The PLS-00215 error seems odd, since the test data I'm using for the options is literally, {"First", "Second", "Third"}. – Paul Jun 21 '12 at 00:12
  • Putting a size after the VARCHAR2 fixed the ORA-06550 and PLS-00215 error, and adding 'INDEX BY PLS_INTEGER' at the end of each TYPE declaration fixed the PLS-00382 and PLS-00320 errors. Now I just have to solve an ORA-01745 and ORA-06512 set of errors ('invalid host/bind variable name at line 8'). For some reason it doesn't appear to like either the 'i' or the 't_ids', not sure which yet. – Paul Jun 21 '12 at 22:22
  • Thanks for updating the answer with the changes, and changing :option to :options. To get it working I also changed the :id, :option and :state_id within the EXECUTE IMMEDIATE statement to :1, :2, :3. I believe the issue was actually just with :option, since 'option' is a reserved word. The last hurdle was one that was unknown, since I didn't specify the table definition. The Options column was a Blob, and as I discovered Oracle doesn't support passing arrays of Blobs via the PL/SQL Associative Array (http://goo.gl/cRBYv http://goo.gl/SXEFS http://goo.gl/qt0UT); I changed it to Long Raw. – Paul Jun 22 '12 at 18:42
  • For anyone who is similarly frustrated with an issue like this, it's not currently updating properly with the change of the column to Long Raw, but I'm marking this as the answer since it works within the problem as initially defined, as well as the fact that tomi44g put me on the right track and provided the core of the answer. If I find the solution for passing in arrays of byte arrays to Long Raw or whatever datatype happens to be correct, I'll post it here as well. Thank you tomi44g. – Paul Jun 22 '12 at 18:46
  • Okay, so changing the column to Long Raw does work, I just needed to do a Commit with my test data before it would apply the Update from the Merge. – Paul Jun 22 '12 at 21:16