Problem
Wish to insert thousands of records into a table [Child]
from a CSV file (and do this from tens of files into the same table). However, I want to A. substitute a lookup value into the table (column values from [Parent]
) and B. perform other computations on CSV-included data before the insert is made. How is this best accomplished?
The lookup is basically that I wish to return an IDENTITY
-based primary key value (as Child.P_ID
) from [Parent]
where [Parent].NVarCharValue,[Parent].DateTimeValue
match the first two fields from the CSV data, but I would rather not bother storing NVarCharValue
and DateTimeValue
themselves into [Child]
, as future joins with other child-level tables will be on the P_ID
field and this text info is redundant and would chew up space and perhaps cause additional lag time on insertions (although perhaps not as much as the lookups will be…but alas…).
For example of computationally-derived data (B.), I’d like to take encoded GPS coordinates in [d]ddmm.mmmm,C (DegreesDecimalMinutes,Direction) format and turn it into a single decimal degrees field (or half of a geography::Point). For example, my CSV contains such lovely entries as 3748.9729,S
, which should be parsed as 37° 48.9729’ S, then translate that to -37.816215 which would be stored for Latitude.
Options? I came up with 5 possibilities, but I don’t want to jump into coding any one of them unless I’ve considered all the pitfalls of an approach.
Option 1: Basic Insert Into
view + BULK INSERT
Even though I intend to only insert into the child table, the multi-table join needed to do the lookup probably violates the MS constraint on inserting a row into multiple underlying tables. It certainly would violate the constraint on no computations or aggregations specified in a VIEW
’s DDL if inserting into that view.
Otherwise, this is (essentially) what I’d like to do
CREATE VIEW [dbo].[ivChild] as
SELECT
P.[ NVarCharValue],
P.[ DateTimeValue],
CAST(CAST((ABS(C.Latitude) as Integer) as VarChar(3))+STR(ABS(C.Latitude-CAST(C.Latitude as Integer))*60.0,9,6) as LatitudeMagnitude --not quite correct (single digit minutes), but close enough for the example
CASE
WHEN SIGN(C.Latitude)>0 THEN ‘N’
ELSE ‘S’
END as LatitudeDir,
CAST(CAST((ABS(C.Longitude) as Integer) as VarChar(3))+STR(ABS(C.Longitude-CAST(C.Longitude as Integer))*60.0,9,6) as LongitudeMagnitude
CASE
WHEN SIGN(C.Latitude)>0 THEN ‘E’
ELSE ‘W’
END as LongitudeDir
FROM Child as C join Parent as P on P.ID=C.P_id
But maybe I can have a subquery for the first two to have the insert take effect only on Child
?
e.g.
SELECT
(SELECT P.NVarCharValue from Parent as P on P.ID=C.P_id) as NVarCharValue
(SELECT P.DateTimeValue from Parent as P on P.ID=C.P_id) as DateTimeValue
…
Option 2: BULK INSERT
into Staging Table, Stored procedure merge
into actual data table?
Insert the NVarCharValue
+ DateTimeValue
columns from the CSV into the staging table, then do either a MERGE INTO
or INSERT INTO
query to migrate the values to the data table, performing computations in-line (if possible) to perform the lookup and parse the string input values to final stored value (e.g. decimal degrees from string and then stored as decimal(10,6)).
Option 3: Create custom CLR for GPSMagnitude
which would parse the incoming string in C#, store it internally to the class as a decimal number, and have an auto-convert to decimal datatype function. Something like
public static implicit operator Decimal(GPSMagnitude c) # GPS->decimal
{ return c._decimalMagnitude; }
Public static implicit operator GPSMagnitude(Decimal c) # decimal->GPS
{ return new GPSMagnitude(c); }
Public static implicit operator ToString(GPSMagnitude c)# GPS->string
{
d = (int)c;
m = (c-(int)c)*60.0;
return d.ToString()+m.ToString();
}
[SqlMethod(OnNullCall = false)]
public GPSMagnitude Parse(SqlString s) # string->GPS
{
Decimal f;
int Deg;
float Mn;
Int32.TryParse(s, out f);
Deg = ((int)f)/100
Mn = f – Deg*100.0
return new GPSMagnitude(Deg + Mn/60)
}
Then use a view with CAST(LatitudeMagnitude as GPSMagnitude)
specified and have an underlying table with LatitudeMagnitude
specified as decimal(10,6)
. I’d still have to do some funny business to get merge two columns to get the magnitude signed (S/W are negative), as well as I would have to independently deal with the P_ID lookup issue.
Option 4: BULK INSERT
with FIRE_TRIGGERS
Do the parsing in a custom trigger, which will take a string input and send it to the table as a decimal. Slows down BULK INSERT considerably from what I hear, but I should be able to both do a lookup and parse the GPS text. I haven’t given a whole lot of thought to what the TRIGGER code would be.
Option 5: Pre-parse my CSV
in e.g. Python to do the GPS calculations, combining the magnitude and sign columns. I’d still have to deal with the P_ID lookup issue though
Option 6: Your solution to my ugly input dataset.
I can flesh out more of these options if you're confused by what I have:P
Based on Solomon's suggestion, I'm beginning to implement a C# stream-parser.
To grab the existing structure off a table, the following code should work (with a few tweaks possibly). Note that this uses else ifs
rather than switch case
as it seems more code-compact (not so many break statements needed, etc). I'm not sure which would be more efficient. I also haven't grabbed of all the columns to cover all contingencies, but for the most basic types it should work okay. Again...this is just for approximating the table fields you already have set up in SQL server.
public SqlMetaData[] ServerLookupFields()
{
using (SqlConnection conn = new SqlConnection(ParserDict.ConnectionString))
{
SqlCommand command;
int siz;
Console.WriteLine("Activating SQL connection");
conn.Open();
Console.WriteLine("SQL connection open");
string num = "select count(*) as cnt from sys.columns as sc join sys.tables as st on sc.object_id = st.object_id where st.name=@table_name;";
command = new SqlCommand(num, conn);
command.Parameters.AddWithValue("@table_name", this.tableDestination);
string result = command.ExecuteScalar().ToString();
Int32.TryParse(result, out siz);
command.Dispose();
SqlMetaData[] smd = new SqlMetaData[siz];
string cmd = "exec sp_columns @table_name=@table_name, @table_owner=dbo";
command = new SqlCommand(cmd, conn);
command.Parameters.AddWithValue("@table_name", this.tableDestination);
SqlDataReader rd = command.ExecuteReader();
int i = 0;
while (rd.Read())
{
smd[i] = ReadSingleMetaRow((IDataRecord)rd);
Console.WriteLine(smd[i].Name.ToString()+","+smd[i].DbType.ToString());
i++;
}
rd.Close();
command.Dispose();
return smd;
}
}
private SqlMetaData ReadSingleMetaRow(IDataRecord meta)
{
bool nullable;
byte precision;
byte length;
byte scale;
SqlMetaData smd;
string columnName = meta[3].ToString(); //Column_name
string dataType = meta[5].ToString(); //Type_name
string dt_fw_lower = dataType.ToLower().Split(new Char[] { ' ' }, 2)[0]; //dataType_firstWord_ToLower
byte.TryParse(meta[6].ToString(), out precision); //Precision
byte.TryParse(meta[7].ToString(), out length); //Length
byte.TryParse(meta[8].ToString(), out scale); //Scale
Boolean.TryParse(meta[10].ToString(), out nullable); //Nullable
string datetimesub = meta[14].ToString(); //DateTimeSub(type)
if (dt_fw_lower == "datetime2") { smd = new SqlMetaData(columnName, SqlDbType.DateTime2); }
else if (dt_fw_lower == "nvarchar") { smd = new SqlMetaData(columnName, SqlDbType.NVarChar, length); }
else if (dt_fw_lower == "varchar") { smd = new SqlMetaData(columnName, SqlDbType.VarChar, length); }
else if (dt_fw_lower == "bigint") { smd = new SqlMetaData(columnName, SqlDbType.BigInt); }
else if (dt_fw_lower == "float") { smd = new SqlMetaData(columnName, SqlDbType.Float, precision, length); }
else if (dt_fw_lower == "decimal") { smd = new SqlMetaData(columnName, SqlDbType.Decimal, precision, scale); }
else if (dt_fw_lower == "binary") { smd = new SqlMetaData(columnName, SqlDbType.Binary, length); }
else if (dt_fw_lower == "varbinary") { smd = new SqlMetaData(columnName, SqlDbType.VarBinary, length); }
else if (dt_fw_lower == "bit") { smd = new SqlMetaData(columnName, SqlDbType.Bit); }
else if (dt_fw_lower == "char") { smd = new SqlMetaData(columnName, SqlDbType.Char, length); }
else if (dt_fw_lower == "nchar") { smd = new SqlMetaData(columnName, SqlDbType.NChar, length); }
else if (dt_fw_lower == "datetimeoffset") { smd = new SqlMetaData(columnName, SqlDbType.DateTimeOffset); }
else if (dt_fw_lower == "datetime") { smd = new SqlMetaData(columnName, SqlDbType.DateTime); }
else if (dt_fw_lower == "date") { smd = new SqlMetaData(columnName, SqlDbType.Date, length); }
else if (dt_fw_lower == "image") { smd = new SqlMetaData(columnName, SqlDbType.Image, length); }
else if (dt_fw_lower == "int") { smd = new SqlMetaData(columnName, SqlDbType.Int); }
else if (dt_fw_lower == "money") { smd = new SqlMetaData(columnName, SqlDbType.Money, length); }
else if (dt_fw_lower == "ntext") { smd = new SqlMetaData(columnName, SqlDbType.NText, length); }
else if (dt_fw_lower == "real") { smd = new SqlMetaData(columnName, SqlDbType.Real, precision, scale); }
else if (dt_fw_lower == "smalldatetime") { smd = new SqlMetaData(columnName, SqlDbType.SmallDateTime); }
else if (dt_fw_lower == "smallint") { smd = new SqlMetaData(columnName, SqlDbType.SmallInt); }
else if (dt_fw_lower == "smallmoney") { smd = new SqlMetaData(columnName, SqlDbType.SmallMoney); }
else if (dt_fw_lower == "structured") { smd = new SqlMetaData(columnName, SqlDbType.Structured, length); }
else if (dt_fw_lower == "text") { smd = new SqlMetaData(columnName, SqlDbType.Text, -1); }
else if (dt_fw_lower == "timestamp") { smd = new SqlMetaData(columnName, SqlDbType.Timestamp); }
else if (dt_fw_lower == "time") { smd = new SqlMetaData(columnName, SqlDbType.Time); }
else if (dt_fw_lower == "tinyint") { smd = new SqlMetaData(columnName, SqlDbType.TinyInt); }
else if (dt_fw_lower == "udt") { smd = new SqlMetaData(columnName, SqlDbType.Udt, length); }
else if (dt_fw_lower == "uniqueidentifier") { smd = new SqlMetaData(columnName, SqlDbType.UniqueIdentifier, length); }
else if (dt_fw_lower == "variant") { smd = new SqlMetaData(columnName, SqlDbType.Variant, length); }
else if (dt_fw_lower == "xml") { smd = new SqlMetaData(columnName, SqlDbType.Xml, length); }
else { smd = new SqlMetaData(columnName, 0); }
return smd;
}
SqlParameter
s, you shouldn't useAddWithValue
as you can run into problems with that method. Is best to create it with a specific datatype. Also, I don't think thatdate
should come beforedatetimeoffset
anddatetime
. Why apply theToLower()
per eachif
? Won't that run the same operation 30 or so times if the type isxml
? It can be done once when you get it frommeta[5]
, right? Finally, why bother withStartsWith()
anyway instead of switching on the actualname.ToLower()
? That is what I have always done and haven't run into any problems (yet ;-).StartsWith()
is due to trailing spaces then you can useTrim()
, or better yet, get rid ofsp_columns
. Personally, I would never use any of those providedsp_*
procs (e.g.sp_tables
, etc.). A lot of them format the output to look nice as a report, or possible from SQLCMD. Either way, you are better off not using them. If there is a JOIN that you can't seem to figure out, look at the body of it viaEXEC sp_helptext 'sp_columns'
. Take what you need and write a simple query to get just what you need.