I have some data that I need to output as rows containing markup tags, which I'm doing inside a table valued function.
This has been working fine up to a point using code in the format below, using the search query to gather my data, and then inserting into my returned table using the output from results.
I now need to take a longer data field and split it up over a number of rows, and I'm at something of a loss as to how to achieve this.
I started with the idea that I wanted to use a CTE to process the data from my query, but I can't see a way to get the data from my search query into my CTE and from there into my results set.
I guess I can see an alternative way of doing this by creating another table valued function in the database that returns a results set if I feed it my comment_text column, but it seems like a waste to do it that way.
Does anyone see a route through to a solution?
Example "Real" Table:
DECLARE #Comments TABLE
(
id INT NOT NULL IDENTITY PRIMARY KEY CLUSTERED,
comment_date DATETIME NOT NULL,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
comment_title VARCHAR(50) NOT NULL,
comment_text char(500)
);
Add Comment Rows:
INSERT INTO #Comments VALUES(CURRENT_TIMESTAMP, 'Bob', 'Example','Bob''s Comment', 'Text of Bob''s comment.');
INSERT INTO #Comments VALUES(CURRENT_TIMESTAMP, 'Alice', 'Example','Alice''s Comment', 'Text of Alice''s comment that is much longer and will need to be split over multiple rows.');
Format of returned results table:
DECLARE #return_table TABLE
(
comment_date DATETIME,
commenter_name VARCHAR(101),
markup VARCHAR(100)
);
Naive query (Can't run because the variable comment_text in the SplitComment CTE can't be identified.
WITH SplitComment(note,start_idx) AS
(
SELECT '<Note>'+SUBSTRING(comment_text,0,50)+'</Note>', 0
UNION ALL
SELECT '<Text>'+SUBSTRING(note,start_idx,50)+'</Text>', start_idx+50 FROM SplitComment WHERE (start_idx+50) < LEN(note)
)
INSERT INTO #return_table
SELECT results.* FROM
(
SELECT
comment_date,
CAST(first_name+' '+last_name AS VARCHAR(101)) commenter,
comment_title,
comment_text
FROM #Comments
) AS search
CROSS APPLY
(
SELECT comment_date, commenter, '<title>'+comment_title+'</title>' markup
UNION ALL SELECT comment_date, commenter, SplitComment
) AS results;
SELECT * FROM #return_table;
Results (when the function is run without the CTE):
comment_date commenter_name markup
2017-07-07 11:53:57.240 Bob Example <title>Bob's Comment</title>
2017-07-07 11:53:57.240 Alice Example <title>Alice's Comment</title>
Ideally, I'd like to get one additional row for Bob's comment, and two rows for Alice's comment. Something like this:
comment_date commenter_name markup
2017-07-07 11:53:57.240 Bob Example <title>Bob's Comment</title>
2017-07-07 11:53:57.240 Bob Example <Note>Bob's Comment</Note>
2017-07-07 11:53:57.240 Alice Example <title>Alice's Comment</title>
2017-07-07 11:53:57.240 Alice Example <Note>Text of Alice''s comment that is much longer and w</Note>
2017-07-07 11:53:57.240 Alice Example <Text>ill need to be split over multiple rows.</Text>
May be you are looking for something like this (it' a simplified version, I used only first name and comment_date as "identifier").
I tested it using this data and - for the moment - imaging max len 50 to split text column.
Tip: change comment_text datatype to VARCHAR(500)
DECLARE #Comments TABLE
(
id INT NOT NULL IDENTITY PRIMARY KEY CLUSTERED,
comment_date DATETIME NOT NULL,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
comment_title VARCHAR(50) NOT NULL,
comment_text VARCHAR(500)
);
INSERT INTO #Comments VALUES(CURRENT_TIMESTAMP, 'Bob', 'Example','Bob''s Comment', 'Text of Bob''s comment.');
INSERT INTO #Comments VALUES(CURRENT_TIMESTAMP, 'Alice', 'Example','Alice''s Comment'
, 'Text of Alice''s comment that is much longer and will need to be split over multiple rows aaaaaa bbbbbb cccccc ddddddddddd eeeeeeeeeeee fffffffffffff ggggggggggggg.');
WITH CTE AS (SELECT comment_date, first_name, '<Note>'+CAST( SUBSTRING(comment_text, 1, 50) AS VARCHAR(500)) +'</Note>'comment_text, 1 AS RN
FROM #Comments
UNION ALL
SELECT A.comment_date, A.first_name, '<Text>'+CAST( SUBSTRING(A.comment_text, B.RN*50+1, 50) AS VARCHAR(500)) +'</Text>'AS comment_text, B.RN+1 AS RN
FROM #Comments A
INNER JOIN CTE B ON A.comment_date=B.comment_date AND A.first_name=B.first_name
WHERE LEN(A.comment_text) > B.RN*50+1
)
SELECT A.comment_date, A.first_name, '<title>'+ comment_title+'</title>' AS markup
FROM #Comments A
UNION ALL
SELECT B.comment_date, B.first_name, B.comment_text AS markup
FROM CTE B ;
Output:
comment_date first_name markup
2017-07-07 14:30:51.117 Bob <title>Bob's Comment</title>
2017-07-07 14:30:51.117 Alice <title>Alice's Comment</title>
2017-07-07 14:30:51.117 Bob <Note>Text of Bob's comment.</Note>
2017-07-07 14:30:51.117 Alice <Note>Text of Alice's comment that is much longer and wi</Note>
2017-07-07 14:30:51.117 Alice <Text>ll need to be split over multiple rows aaaaaa bbbb</Text>
2017-07-07 14:30:51.117 Alice <Text>bb cccccc ddddddddddd eeeeeeeeeeee fffffffffffff g</Text>
2017-07-07 14:30:51.117 Alice <Text>gggggggggggg.</Text>
Here's a solution that also allows sorting the resultset
It uses a recursive CTE to calculate the positions in the long text.
And by joining the table to the CTE, the text can be sliced up into rows.
with cte as
(
select id, 1 as lvl, len(comment_text) as posmax, 1 pos1, 50 limit
from #Comments
union all
select id, lvl + 1, posmax, iif(pos1+limit<posmax,pos1+limit,posmax), limit
from cte
where pos1+limit<posmax
)
, CTE2 AS
(
select id, 0 as lvl,
comment_date,
concat(first_name,' ',last_name) as commenter,
'<Title>'+rtrim(comment_title)+'</Title>' as markup
from #Comments
union all
select t.id, c.lvl,
comment_date,
concat(first_name,' ',last_name) as commenter_name,
concat(iif(lvl=1,'<Note>','<Text>'),substring(comment_text,pos1,limit),iif(lvl=1,'</Note>','</Text>')) as markup
from #Comments t
join cte c on c.id = t.id
)
select comment_date, commenter, markup
from CTE2
order by id, lvl;
Output:
comment_date commenter markup
----------------------- ------------ -------------------------------------
2017-07-07 15:06:31.293 Bob Example <Title>Bob's Comment</Title>
2017-07-07 15:06:31.293 Bob Example <Note>Text of Bob's comment.</Note>
2017-07-07 15:06:31.293 Alice Example <Title>Alice's Comment</Title>
2017-07-07 15:06:31.293 Alice Example <Note>Text of Alice's comment that is much longer and wi</Note>
2017-07-07 15:06:31.293 Alice Example <Text>ll need to be split over multiple rows.</Text>
Related
I have some data, contact_IDs, that are in an Array of Structs inside a table called deals as in the example below.
WITH deals AS (
Select "012345" as deal_ID,
[STRUCT(["abc"] as company_ID, [123,678,810] as contact_ID)]
AS associations)
SELECT
deal_ID,
ARRAY(
SELECT AS STRUCT
( SELECT STRING_AGG(CAST(id AS STRING), ', ')
FROM t.contact_ID id
) AS contact_ID
FROM d.associations t
) AS contacts
FROM deals d
The query above takes the contact_IDs in the associations array and list them separated by commas.
Row deal_ID contacts.contact_ID
1 012345 123, 678, 810
But my problem now is that I need to replace the contact_IDs with with first and last names from another table called contacts that looks like the following where contact_ID is INT64 and the name fields are Strings.
contact_id first_name last_name
123 Jane Doe
678 John Smith
810 Alice Acre
I've attempted doing it with a subquery like this:
WITH deals AS (
Select "012345" as deal_ID,
[STRUCT(["abc"] as company_ID, [123,678,810] as contact_ID)]
AS associations)
SELECT
deal_ID,
ARRAY(
SELECT AS STRUCT
company_ID,
( SELECT STRING_AGG(
(select concat(c.first_name, " ", c.last_name)
from contacts c
where c.contact_id=id), ', ')
FROM t.contact_ID id
) AS contact_name
FROM d.associations t
) AS contacts
FROM deals d
But this gives an error "Correlated subqueries that reference other tables are not supported unless they can be de-correlated, such as by transforming them into an efficient JOIN." But I can't figure out how to make a join between deals.associations.contact_ID and contacts.contact_id when the thing I need to be joining on is inside the deals.associations array...
Thanks in advance for any guidance.
Below is for BigQuery Standard SQL
#standardSQL
SELECT deal_ID,
ARRAY_AGG(STRUCT(company_ID, contact_name)) AS contacts
FROM (
SELECT
deal_ID,
ANY_VALUE(company_ID) AS company_ID,
STRING_AGG(FORMAT('%s %s', IFNULL(first_name, ''), IFNULL(last_name, '')), ', ') AS contact_name
FROM deals d,
d.associations AS contact,
contact.contact_ID AS contact_ID
LEFT JOIN contacts c
USING(contact_ID)
GROUP BY deal_ID, FORMAT('%t', company_ID)
)
GROUP BY deal_ID
if applied to sample data from your question - output is
Row deal_ID contacts.company_ID contacts.contact_name
1 012345 abc Jane Doe, John Smith, Alice Acre
Note - below
FROM deals d,
d.associations AS contact,
contact.contact_ID AS contact_ID
is a shortcut for
FROM deals,
UNNEST(associations) AS contact,
UNNEST(contact_ID) AS contact_ID
Somehow - this is my preference when possible not to use explicit UNNEST() in the query text
I'm struggling to get following 3 tables into one query:
tPerson
ID FirstName
1 'Jack'
2 'Liz'
tAttribute
ID AttributeName
101 'LastName'
102 'Gender'
tData
PersonID AttributeID AttributeValue
1 101 'Nicholson'
1 102 'Male'
2 101 'Taylor'
2 102 'Female'
Important: The attributes in tAttribute are dynamic. There could be more, e.g.:
ID AttributeName
103 'Income'
104 'MostPopularMovie'
Question: How can I write my query (or queries if neccessary), so that I get following output:
PersonID FirstName LastName Gender [otherFields]
1 'Jack' 'Nicholson' 'Male' [otherValues]
2 'Liz' 'Taylor' 'Female' [otherValues]
I often read "What have you tried so far?", but posting all my failed attempts using subqueries and joins wouldn't make much sense. I'm just not that secure with SQL.
Many thanks in advance.
Thanks to #Tab Alleman, I google for "SQL PIVOT" and came up with following result:
SELECT PersonID,
FirstName,
[LastName],
[Gender]
FROM (
SELECT tPerson.ID AS PersonID,
tPerson.FirstName,
tAttribute.AttributeName,
tData.AttributeValue
FROM tAttribute
INNER JOIN tData ON (
tAttribute.ID = tData.AttributeID
)
INNER JOIN tPerson ON (
tData.PersonID = tPerson.ID
)
) AS unPivotResult
PIVOT (
MAX(AttributeValue)
FOR AttributeName IN ([LastName],[Gender])
) AS pivotResult
Addition: I didn't know how to get LastName and Gender dynamically via SQL, so I did that with ColdFusion, which I use for programming. It will look like this:
<!--- "local.attributes" gets generated by making another query,--->
<!--- I just wrote it statically here for this example --->
<cfset local.attributes = "[LastName],[Gender]" />
<cfquery name="local.persons">
SELECT PersonID,
FirstName,
#local.attributes#
FROM (
...
) AS unPivotResult
PIVOT (
MAX(AttributeValue)
FOR AttributeName IN (#local.attributes#)
) AS pivotResult
</cfquery>
It'd be cool, if I could replace the ColdFusion part with something like
SELECT AttributeName FROM tAttribute and then use that to get the brackets-definition.
i have a table some departments tagged with user as
User | Department
user1 | IT,HR,House Keeping
user2 | HR,House Keeping
user3 | IT,Finance,HR,Maintainance
user4 | Finance,HR,House Keeping
user5 | IT,HR,Finance
i have created a SP that take parameter varchar(max) as filter (i dynamically merged if in C# code)
in the sp i creat a temp table for the selected filters eg; if user select IT & Finance & HR
i merged the string as IT##Finance##HR (in C#) & call the sp with this parameter
in SP i make a temp table as
FilterValue
IT
Finance
HR
now the issue how can i get the records that contains all the departments taged with them
(users that are associated with all the values in temp table) to get
User | Department
user3 | IT,Finance,HR,Maintainance
user5 | IT,HR,Finance
as optput
please suggest an optimised way to achive this filtering
This design is beyond horrible -- you should really change this to use truly relational design with a dependent table.
That said, if you are not in a position to change the design, you can limp around the problem with XML, and it might give you OK performance.
Try something like this (replace '#test' with your table name as needed...). You won't need to even create your temp table -- this will jimmy your comma-delimited string around into XML, which you can then use XQuery against directly:
DECLARE #test TABLE (usr int, department varchar(1000))
insert into #test (usr, department)
values (1, 'IT,HR,House Keeping')
insert into #test (usr, department)
values (2, 'HR,House Keeping')
insert into #test (usr, department)
values (3, 'IT,Finance,HR,Maintainance')
insert into #test (usr, department)
values (4, 'Finance,HR,House Keeping')
insert into #test (usr, department)
values (5, 'IT,HR,Finance')
;WITH departments (usr, department, depts)
AS
(
SELECT usr, department, CAST(NULLIF('<department><dept>' + REPLACE(department, ',', '</dept><dept>') + '</dept></department>', '<department><dept></dept></department>') AS xml)
FROM #test
)
SELECT departments.usr, departments.department
FROM departments
WHERE departments.depts.exist('/department/dept[text()[1] eq "IT"]') = 1
AND departments.depts.exist('/department/dept[text()[1] eq "HR"]') = 1
AND departments.depts.exist('/department/dept[text()[1] eq "Finance"]') = 1
I agree with others that your design is, um, not ideal, and given the fact that it may change, as per your comment, one is not too motivated to find a really fascinating solution for the present situation.
Still, you can have one that at least works correctly (I think) and meets the situation. Here's what I've come up with:
;
WITH
UserDepartment ([User], Department) AS (
SELECT 'user1', 'IT,HR,House Keeping' UNION ALL
SELECT 'user2', 'HR,House Keeping' UNION ALL
SELECT 'user3', 'IT,Finance,HR,Maintainance' UNION ALL
SELECT 'user4', 'Finance,HR,House Keeping' UNION ALL
SELECT 'user5', 'IT,HR,Finance'
),
Filter (FilterValue) AS (
SELECT 'IT' UNION ALL
SELECT 'Finance' UNION ALL
SELECT 'HR'
),
CSVSplit AS (
SELECT
ud.*,
--x.node.value('.', 'varchar(max)')
x.Value AS aDepartment
FROM UserDepartment ud
CROSS APPLY (SELECT * FROM dbo.Split(',', ud.Department)) x
)
SELECT
c.[User],
c.Department
FROM CSVSplit c
INNER JOIN Filter f ON c.aDepartment = f.FilterValue
GROUP BY
c.[User],
c.Department
HAVING
COUNT(*) = (SELECT COUNT(*) FROM Filter)
The first two CTEs are just sample tables, the rest of the query is the solution proper.
The CSVSplit CTE uses a Split function that splits a comma-separated list into a set of items and returns them as a table. The entire CTE turns the row set of the form
----- ---------------------------
user1 department1,department2,...
... ...
into this:
----- -----------
user1 department1
user1 department2
... ...
The main SELECT joins the normalised row set with the filter table and selects rows where the number of matches exactly equals the number of items in the filter table. (Note: this implies there's no identical names in UserDepartment.Department).
I have a table that contains unfortuantely bad data and I'm trying to filter some out. I am sure that the LName, FName combonation is unique since the data set is small enough to verify.
LName, FName, Email
----- ----- -----
Smith Bob bsmith#example.com
Smith Bob NULL
Doe Jane NULL
White Don dwhite#example.com
I would like to have the query results bring back the "duplicate" record that does not have a NULL email, yet still bring back a NULL Email when there is not a duplicate.
E.g.
Smith Bob bsmith#example.com
Doe Jane NULL
White Don dwhite#example.com
I think the solution is similar to Sql, remove duplicate rows by value, but I don't really understand if the asker's requirements are the same as mine.
Any suggestions?
Thanks
You can use ROW_NUMBER() analytic function:
SELECT *
FROM (
SELECT a.*, ROW_NUMBER() OVER(PARTITION BY LName, FName ORDER BY Email DESC) rnk
FROM <YOUR_TABLE> a
) a
WHERE RNK = 1
This drops the null rows if there are any non null values.
SELECT lname
, fname
, MIN(email)
FROM YourTable
GROUP BY
lname
, fname
Test script
DECLARE #Test TABLE (
LName VARCHAR(32)
, FName VARCHAR(32)
, Email VARCHAR(32)
)
INSERT INTO #Test
SELECT 'Smith', 'Bob', 'bsmith#example.com'
UNION ALL SELECT 'Smith', 'Bob', 'NULL'
UNION ALL SELECT 'Doe', 'Jane', 'NULL'
UNION ALL SELECT 'White', 'Don', 'dwhite#example.com'
SELECT lname
, fname
, MIN(Email)
FROM #Test
GROUP BY
lname
, fname
Here is a relatively simple query that uses standard SQL and does just this:
SELECT * FROM Person P
WHERE Email IS NOT NULL OR -- Take all people with non-null e-mails
Email IS NULL AND -- and all people with null e-mails, as long as
NOT EXISTS -- there is no duplicate record of the same person
(SELECT * -- with a non-null e-mail
FROM Person P2
WHERE P2.LName=P.LName AND P2.FName=P.FName AND P2.Email IS NOT NULL)
Since there are plenty of SQL solutions posted already, you may want to create a data fix to remove the bad data, then add the necessary constraints to prevent bad data from ever being inserted. Bad data in a database is a side effect of poor design.
I've got a person table that contains an error code field that can contain multiple error codes (001, 002, 003...). I know that's a schema problem but this is a vendor application and I have no control over the schema, so I have to work with what I've got.
There is also a Error table that contains ErrorCode (char(3)) and Descript (char(1000)). In my query the Person.ErrorCode is joined to the Error.ErrorCode to get the value of the corresponding description.
For person records where there is only one error code, I can get the corresponding Descript with no problem. What I'm trying to do is somehow concat the Descript values for records where there are multiple errors.
For example, here's some sample data from Error table:
ErrorCode Descript
001 Problem with person file
002 Problem with address file
003 Problem with grade
Here are the columns resulting from my SELECT on Person with a JOIN on Error:
Person.RecID Person.ErrorCode Error.Descript
12345 001 Problem with person file
12346 003 Problem with grade
12347 002,003
What I'm trying to get is this:
Person.RecID Person.ErrorCode Error.Descript
12345 001 Problem with person file
12346 003 Problem with grade
12347 002,003 Problem with address file, Problem with grade
Suggestions appreciated!
You should see: "Arrays and Lists in SQL Server 2005 and Beyond, When Table Value Parameters Do Not Cut it" by Erland Sommarskog, then there are many ways to split string in SQL Server. This article covers the PROs and CONs of just about every method. in general, you need to create a split function. This is how a split function can be used to join rows:
SELECT
*
FROM dbo.yourSplitFunction(#Parameter) b
INNER JOIN YourCodesTable c ON b.ListValue=c.CodeValue
I prefer the number table approach to split a string in TSQL but there are numerous ways to split strings in SQL Server, see the previous link, which explains the PROs and CONs of each.
For the Numbers Table method to work, you need to do this one time table setup, which will create a table Numbers that contains rows from 1 to 10,000:
SELECT TOP 10000 IDENTITY(int,1,1) AS Number
INTO Numbers
FROM sys.objects s1
CROSS JOIN sys.objects s2
ALTER TABLE Numbers ADD CONSTRAINT PK_Numbers PRIMARY KEY CLUSTERED (Number)
Once the Numbers table is set up, create this split function:
CREATE FUNCTION [dbo].[FN_ListToTable]
(
#SplitOn char(1) --REQUIRED, the character to split the #List string on
,#List varchar(8000)--REQUIRED, the list to split apart
)
RETURNS TABLE
AS
RETURN
(
----------------
--SINGLE QUERY-- --this will not return empty rows
----------------
SELECT
ListValue
FROM (SELECT
LTRIM(RTRIM(SUBSTRING(List2, number+1, CHARINDEX(#SplitOn, List2, number+1)-number - 1))) AS ListValue
FROM (
SELECT #SplitOn + #List + #SplitOn AS List2
) AS dt
INNER JOIN Numbers n ON n.Number < LEN(dt.List2)
WHERE SUBSTRING(List2, number, 1) = #SplitOn
) dt2
WHERE ListValue IS NOT NULL AND ListValue!=''
);
GO
You can now easily split a CSV string into a table and join on it:
DECLARE #ErrorCode table (ErrorCode varchar(20), Description varchar(30))
INSERT #ErrorCode VALUES ('001','Problem with person file')
INSERT #ErrorCode VALUES ('002','Problem with address file')
INSERT #ErrorCode VALUES ('003','Problem with grade')
DECLARE #Person table (RecID int, ErrorCode varchar(20))
INSERT #Person VALUES (12345 ,'001' )
INSERT #Person VALUES (12346 ,'003' )
INSERT #Person VALUES (12347 ,'002,003')
SELECT
p.RecID,c.ListValue,e.Description
FROM #Person p
CROSS APPLY dbo.FN_ListToTable(',',p.ErrorCode) c
INNER JOIN #ErrorCode e ON c.ListValue=e.ErrorCode
OUTPUT:
RecID ListValue Description
----------- ------------- -------------------------
12345 001 Problem with person file
12346 003 Problem with grade
12347 002 Problem with address file
12347 003 Problem with grade
(4 row(s) affected)
you can use the XML trick to concatenate the rows back together:
SELECT
t1.RecID,t1.ErrorCode
,STUFF(
(SELECT
', ' + e.Description
FROM #Person p
CROSS APPLY dbo.FN_ListToTable(',',p.ErrorCode) c
INNER JOIN #ErrorCode e ON c.ListValue=e.ErrorCode
WHERE t1.RecID=p.RecID
ORDER BY p.ErrorCode
FOR XML PATH(''), TYPE
).value('.','varchar(max)')
,1,2, ''
) AS ChildValues
FROM #Person t1
GROUP BY t1.RecID,t1.ErrorCode
OUTPUT:
RecID ErrorCode ChildValues
----------- -------------------- -----------------------------------------------
12345 001 Problem with person file
12346 003 Problem with grade
12347 002,003 Problem with address file, Problem with grade
(3 row(s) affected)
This returns the same result set as above, but may perform better:
SELECT
t1.RecID,t1.ErrorCode
,STUFF(
(SELECT
', ' + e.Description
FROM (SELECT ListValue FROM dbo.FN_ListToTable(',',t1.ErrorCode)) c
INNER JOIN #ErrorCode e ON c.ListValue=e.ErrorCode
ORDER BY c.ListValue
FOR XML PATH(''), TYPE
).value('.','varchar(max)')
,1,2, ''
) AS ChildValues
FROM #Person t1
GROUP BY t1.RecID,t1.ErrorCode
Denormalize person.errorcode before the join with error.errorcode
I don't mean denormalize on the table level, I mean with a view or sql code.
You can use a Common Table Expression to pretend that the person table is normal:
;WITH PersonPrime as (
SELECT RecID,ErrorCode,CAST(null as varchar(100)) as Remain from Person where Value not like '%,%'
UNION ALL
SELECT RecID,SUBSTRING(ErrorCode,1,CHARINDEX(',',ErrorCode)-1),SUBSTRING(ErrorCode,CHARINDEX(',',ErrorCode)+1,100) from Person where Value like '%,%'
UNION ALL
SELECT RecID,Remain,null FROM PersonPrime where Remain not like '%,%'
UNION ALL
SELECT RecID,SUBSTRING(Remain,1,CHARINDEX(',',Remain)-1),SUBSTRING(Remain,CHARINDEX(',',Remain)+1,100) from PersonPrime where Remain like '%,%'
)
SELECT RecID,ErrorCode from PersonPrime
And now use PersonPrime where you'd have used Person in your original query. You'll want to CAST that null to a varchar column as wide as ErrorCode in the Person table
Concatenating the error descriptions might not be the way to go. It adds unnecessary complexity to the SQL statement that will be extremely problematic to debug. Any future additions or changes to the SQL will also be difficult. Your best bet is to generate a resultset that is normalized, even though your schema is not.
SELECT Person.RecID, Person.ErrorCode, Error.ErrorCode, Error.Descript
FROM Person INNER JOIN Error
ON REPLACE(Person.ErrorCode, ' ', '') LIKE '%,' + CONVERT(VARCHAR,Error.ErrorCode) + ',%'
If a person has multiple error codes set, then this will return one row for each error specified (ignoring duplicates). Using your example, it will return this.
Person.RecID Person.ErrorCode Error.ErrorCode Error.Descript
12345 001 001 Problem with person file
12346 003 003 Problem with grade
12347 002,003 002 Problem with address file
12347 002,003 003 Problem with grade
By grouping the errors together and concatenating them is an option:
SELECT *, GROUP_CONCAT(Person.ErrorCode) FROM Person
GROUP BY Person.RecID