Le tableau d'historique utilisateur suivant contient un enregistrement pour chaque jour où un utilisateur donné a accédé à un site Web (dans une période UTC de 24 heures). Il contient plusieurs milliers d'enregistrements, mais un seul enregistrement par jour et par utilisateur. Si l'utilisateur n'a pas accédé au site Web ce jour-là, aucun enregistrement ne sera généré.
Id UserId CreationDate ------ ------ ------------ 750997 12 2009-07-07 18 : 42: 20.723 750998 15 2009-07-07 18: 42: 20.927 751000 19 2009-07-07 18: 42: 22.283
Ce que je recherche, c'est une requête SQL sur ce tableau avec de bonnes performances, qui me dit quels userids ont accédé au site web pendant (n) jours continus sans en manquer un.
En d'autres termes, combien d'utilisateurs ont (n) enregistrements dans ce tableau avec des dates séquentielles (jour avant ou après) ? Si un jour manque dans la séquence, la séquence est interrompue et doit recommencer à 1; nous recherchons des utilisateurs qui ont atteint un nombre continu de jours ici sans lacunes.
Toute ressemblance entre cette requête et n badge Stack Overflow particulier est purement fortuite, bien sûr .. :)
La réponse est évidemment:
SELECT DISTINCT UserId
FROM UserHistory uh1
WHERE (
SELECT COUNT(*)
FROM UserHistory uh2
WHERE uh2.CreationDate
BETWEEN uh1.CreationDate AND DATEADD(d, @days, uh1.CreationDate)
) = @days OR UserId = 52551
MODIFIER:
D'accord, voici ma réponse sérieuse:
DECLARE @days int
DECLARE @seconds bigint
SET @days = 30
SET @seconds = (@days * 24 * 60 * 60) - 1
SELECT DISTINCT UserId
FROM (
SELECT uh1.UserId, Count(uh1.Id) as Conseq
FROM UserHistory uh1
INNER JOIN UserHistory uh2 ON uh2.CreationDate
BETWEEN uh1.CreationDate AND
DATEADD(s, @seconds, DATEADD(dd, DATEDIFF(dd, 0, uh1.CreationDate), 0))
AND uh1.UserId = uh2.UserId
GROUP BY uh1.Id, uh1.UserId
) as Tbl
WHERE Conseq >= @days
MODIFIER:
[Jeff Atwood] C'est une excellente solution rapide et mérite d'être acceptée, mais la solution de Rob Farley est également excellente et sans doute encore plus rapide (!). Veuillez le vérifier aussi!
Que diriez-vous (et assurez-vous que la déclaration précédente se termine par un point-virgule):
WITH numberedrows
AS (SELECT ROW_NUMBER() OVER (PARTITION BY UserID
ORDER BY CreationDate)
- DATEDIFF(day,'19000101',CreationDate) AS TheOffset,
CreationDate,
UserID
FROM tablename)
SELECT MIN(CreationDate),
MAX(CreationDate),
COUNT(*) AS NumConsecutiveDays,
UserID
FROM numberedrows
GROUP BY UserID,
TheOffset
L'idée étant que si nous avons la liste des jours (sous forme de nombre) et un numéro de ligne, les jours manqués augmentent légèrement le décalage entre ces deux listes. Nous recherchons donc une plage qui a un décalage cohérent.
Vous pouvez utiliser "ORDER BY NumConsecutiveDays DESC" à la fin de cela, ou dire "HAVING count (*)> 14" pour un seuil ...
Je n'ai pas testé cela cependant - je l'ai simplement écrit du haut de ma tête. Espérons que cela fonctionne dans SQL2005 et sur.
... et serait grandement aidé par un index sur le nom de la table (UserID, CreationDate)
Modifié: s'avère que Offset est un mot réservé, j'ai donc utilisé TheOffset à la place.
Modifié: La suggestion d'utiliser COUNT (*) est très valable - j'aurais dû le faire en premier lieu mais je n'y pensais pas vraiment. Auparavant, il utilisait plutôt dateiff (jour, min (CreationDate), max (CreationDate)).
Rob
Si vous pouvez modifier le schéma de la table, je vous suggère d'ajouter une colonne LongestStreak
à la table que vous auriez définie pour le nombre de jours séquentiels se terminant par CreationDate
. Il est facile de mettre à jour la table au moment de la connexion (similaire à ce que vous faites déjà, si aucune ligne n'existe de la journée en cours, vous vérifierez si une ligne existe pour la journée précédente. Si la valeur est vraie, vous incrémenterez le LongestStreak
dans la nouvelle ligne, sinon, vous allez le mettre à 1.)
La requête sera évidente après l'ajout de cette colonne:
if exists(select * from table
where LongestStreak >= 30 and UserId = @UserId)
-- award the Woot badge.
Du SQL bien expressif dans le sens de:
select
userId,
dbo.MaxConsecutiveDates(CreationDate) as blah
from
dbo.Logins
group by
userId
En supposant que vous ayez fonction d'agrégation définie par l'utilisateur quelque chose dans le sens de (attention, c'est bogué):
using System;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;
using System.Runtime.InteropServices;
namespace SqlServerProject1
{
[StructLayout(LayoutKind.Sequential)]
[Serializable]
internal struct MaxConsecutiveState
{
public int CurrentSequentialDays;
public int MaxSequentialDays;
public SqlDateTime LastDate;
}
[Serializable]
[SqlUserDefinedAggregate(
Format.Native,
IsInvariantToNulls = true, //optimizer property
IsInvariantToDuplicates = false, //optimizer property
IsInvariantToOrder = false) //optimizer property
]
[StructLayout(LayoutKind.Sequential)]
public class MaxConsecutiveDates
{
/// <summary>
/// The variable that holds the intermediate result of the concatenation
/// </summary>
private MaxConsecutiveState _intermediateResult;
/// <summary>
/// Initialize the internal data structures
/// </summary>
public void Init()
{
_intermediateResult = new MaxConsecutiveState { LastDate = SqlDateTime.MinValue, CurrentSequentialDays = 0, MaxSequentialDays = 0 };
}
/// <summary>
/// Accumulate the next value, not if the value is null
/// </summary>
/// <param name="value"></param>
public void Accumulate(SqlDateTime value)
{
if (value.IsNull)
{
return;
}
int sequentialDays = _intermediateResult.CurrentSequentialDays;
int maxSequentialDays = _intermediateResult.MaxSequentialDays;
DateTime currentDate = value.Value.Date;
if (currentDate.AddDays(-1).Equals(new DateTime(_intermediateResult.LastDate.TimeTicks)))
sequentialDays++;
else
{
maxSequentialDays = Math.Max(sequentialDays, maxSequentialDays);
sequentialDays = 1;
}
_intermediateResult = new MaxConsecutiveState
{
CurrentSequentialDays = sequentialDays,
LastDate = currentDate,
MaxSequentialDays = maxSequentialDays
};
}
/// <summary>
/// Merge the partially computed aggregate with this aggregate.
/// </summary>
/// <param name="other"></param>
public void Merge(MaxConsecutiveDates other)
{
// add stuff for two separate calculations
}
/// <summary>
/// Called at the end of aggregation, to return the results of the aggregation.
/// </summary>
/// <returns></returns>
public SqlInt32 Terminate()
{
int max = Math.Max((int) ((sbyte) _intermediateResult.CurrentSequentialDays), (sbyte) _intermediateResult.MaxSequentialDays);
return new SqlInt32(max);
}
}
}
On dirait que vous pourriez profiter du fait que pour être continu sur n jours, il faudrait qu'il y ait n lignes.
Donc quelque chose comme:
SELECT users.UserId, count(1) as cnt
FROM users
WHERE users.CreationDate > now() - INTERVAL 30 DAY
GROUP BY UserId
HAVING cnt = 30
Faire cela avec une seule requête SQL me semble trop compliqué. Permettez-moi de décomposer cette réponse en deux parties.
Quelques options SQL Server 2012 (en supposant N = 100 ci-dessous).
;WITH T(UserID, NRowsPrevious)
AS (SELECT UserID,
DATEDIFF(DAY,
LAG(CreationDate, 100)
OVER
(PARTITION BY UserID
ORDER BY CreationDate),
CreationDate)
FROM UserHistory)
SELECT DISTINCT UserID
FROM T
WHERE NRowsPrevious = 100
Bien qu'avec mes exemples de données, les éléments suivants aient été plus efficaces
;WITH U
AS (SELECT DISTINCT UserId
FROM UserHistory) /*Ideally replace with Users table*/
SELECT UserId
FROM U
CROSS APPLY (SELECT TOP 1 *
FROM (SELECT
DATEDIFF(DAY,
LAG(CreationDate, 100)
OVER
(ORDER BY CreationDate),
CreationDate)
FROM UserHistory UH
WHERE U.UserId = UH.UserID) T(NRowsPrevious)
WHERE NRowsPrevious = 100) O
Les deux s'appuient sur la contrainte énoncée dans la question selon laquelle il existe au plus un enregistrement par jour et par utilisateur.
Joe Celko a un chapitre complet à ce sujet dans SQL pour Smarties (en l'appelant Runs and Sequences). Je n'ai pas ce livre à la maison, donc quand je serai au travail ... je répondrai à cela. (en supposant que la table d'historique est appelée dbo.UserHistory et que le nombre de jours est @Days)
Une autre piste vient de le blog de SQL Team sur les runs
L'autre idée que j'ai eue, mais je n'ai pas de serveur SQL à portée de main, c'est d'utiliser un CTE avec un ROW_NUMBER partitionné comme ceci:
WITH Runs
AS
(SELECT UserID
, CreationDate
, ROW_NUMBER() OVER(PARTITION BY UserId
ORDER BY CreationDate)
- ROW_NUMBER() OVER(PARTITION BY UserId, NoBreak
ORDER BY CreationDate) AS RunNumber
FROM
(SELECT UH.UserID
, UH.CreationDate
, ISNULL((SELECT TOP 1 1
FROM dbo.UserHistory AS Prior
WHERE Prior.UserId = UH.UserId
AND Prior.CreationDate
BETWEEN DATEADD(dd, DATEDIFF(dd, 0, UH.CreationDate), -1)
AND DATEADD(dd, DATEDIFF(dd, 0, UH.CreationDate), 0)), 0) AS NoBreak
FROM dbo.UserHistory AS UH) AS Consecutive
)
SELECT UserID, MIN(CreationDate) AS RunStart, MAX(CreationDate) AS RunEnd
FROM Runs
GROUP BY UserID, RunNumber
HAVING DATEDIFF(dd, MIN(CreationDate), MAX(CreationDate)) >= @Days
Ce qui précède est probablement PLUS DUR qu'il ne doit l'être, mais laissé comme un chatouillement cérébral lorsque vous avez une autre définition de "une course" que juste des dates.
Si cela est si important pour vous, sourcez cet événement et conduisez une table pour vous donner ces informations. Pas besoin de tuer la machine avec toutes ces requêtes folles.
Vous pouvez utiliser un CTE récursif (SQL Server 2005+):
WITH recur_date AS (
SELECT t.userid,
t.creationDate,
DATEADD(day, 1, t.created) 'nextDay',
1 'level'
FROM TABLE t
UNION ALL
SELECT t.userid,
t.creationDate,
DATEADD(day, 1, t.created) 'nextDay',
rd.level + 1 'level'
FROM TABLE t
JOIN recur_date rd on t.creationDate = rd.nextDay AND t.userid = rd.userid)
SELECT t.*
FROM recur_date t
WHERE t.level = @numDays
ORDER BY t.userid
J'ai utilisé une simple propriété mathématique pour identifier les personnes qui ont accédé consécutivement au site. Cette propriété est que vous devez avoir la différence de jour entre le premier accès et la dernière fois égale au nombre d'enregistrements dans votre journal de table d'accès.
Voici le script SQL que j'ai testé dans Oracle DB (il devrait également fonctionner dans d'autres bases de données):
-- show basic understand of the math properties
select ceil(max (creation_date) - min (creation_date))
max_min_days_diff,
count ( * ) real_day_count
from user_access_log
group by user_id;
-- select all users that have consecutively accessed the site
select user_id
from user_access_log
group by user_id
having ceil(max (creation_date) - min (creation_date))
/ count ( * ) = 1;
-- get the count of all users that have consecutively accessed the site
select count(user_id) user_count
from user_access_log
group by user_id
having ceil(max (creation_date) - min (creation_date))
/ count ( * ) = 1;
Script de préparation de table:
-- create table
create table user_access_log (id number, user_id number, creation_date date);
-- insert seed data
insert into user_access_log (id, user_id, creation_date)
values (1, 12, sysdate);
insert into user_access_log (id, user_id, creation_date)
values (2, 12, sysdate + 1);
insert into user_access_log (id, user_id, creation_date)
values (3, 12, sysdate + 2);
insert into user_access_log (id, user_id, creation_date)
values (4, 16, sysdate);
insert into user_access_log (id, user_id, creation_date)
values (5, 16, sysdate + 1);
insert into user_access_log (id, user_id, creation_date)
values (6, 16, sysdate + 5);
Quelque chose comme ça?
select distinct userid
from table t1, table t2
where t1.UserId = t2.UserId
AND trunc(t1.CreationDate) = trunc(t2.CreationDate) + n
AND (
select count(*)
from table t3
where t1.UserId = t3.UserId
and CreationDate between trunc(t1.CreationDate) and trunc(t1.CreationDate)+n
) = n
declare @startdate as datetime, @days as int
set @startdate = cast('11 Jan 2009' as datetime) -- The startdate
set @days = 5 -- The number of consecutive days
SELECT userid
,count(1) as [Number of Consecutive Days]
FROM UserHistory
WHERE creationdate >= @startdate
AND creationdate < dateadd(dd, @days, cast(convert(char(11), @startdate, 113) as datetime))
GROUP BY userid
HAVING count(1) >= @days
L'instruction cast(convert(char(11), @startdate, 113) as datetime)
supprime la partie heure de la date, nous commençons donc à minuit.
Je suppose également que les colonnes creationdate
et userid
sont indexées.
Je viens de réaliser que cela ne vous dira pas tous les utilisateurs et leur nombre total de jours consécutifs. Mais vous dira quels utilisateurs auront visité un nombre défini de jours à partir de la date de votre choix.
Solution révisée:
declare @days as int
set @days = 30
select t1.userid
from UserHistory t1
where (select count(1)
from UserHistory t3
where t3.userid = t1.userid
and t3.creationdate >= DATEADD(dd, DATEDIFF(dd, 0, t1.creationdate), 0)
and t3.creationdate < DATEADD(dd, DATEDIFF(dd, 0, t1.creationdate) + @days, 0)
group by t3.userid
) >= @days
group by t1.userid
J'ai vérifié cela et il interrogera tous les utilisateurs et toutes les dates. Il est basé sur 1ère solution (blague?) De Spencer , mais la mienne fonctionne.
Mise à jour: amélioration de la gestion des dates dans la deuxième solution.
Cela devrait faire ce que vous voulez, mais je n'ai pas assez de données pour tester l'efficacité. Le truc CONVERT/FLOOR alambiqué consiste à retirer la partie temporelle du champ datetime. Si vous utilisez SQL Server 2008, vous pouvez utiliser CAST (x.CreationDate AS DATE).
DECLARE @Range as INT SET @Range = 10 SELECT DISTINCT UserId, CONVERT (DATETIME, FLOOR (CONVERT (FLOAT, a.CreationDate)))) DE tblUserLogin a OERE EXISTE (SÉLECTIONNEZ 1 DE tblUserLogin b O a a.userId = b.userId ET (SELECT COUNT (DISTINCT (CONVERT (DATETIME, FLOOR (CONVERT (FLOAT, CreationDate)))))) FROM tblUserLogin c OERE c.userid = b.userid ET CONVERT (DATETIME, FLOOR (CONVERT (FLOAT, c.CreationDate))) ENTRE CONVERT (DATETIME, FLOOR (CONVERT (FLOAT, a.CreationDate))) et CONVERT (DATETIME, FLOOR (CONVERT (FLOAT, a.CreationDate)) @ Range-1) = @Range)
Script de création
CRÉER LE TABLEAU [dbo]. [TblUserLogin] ( [Id] [int] IDENTITÉ (1,1) NOT NULL, [UserId] [int] NULL, [CreationDate] [datetime] NULL ) ON [PRIMARY]
Ajuster un peu la requête de Bill. Vous devrez peut-être tronquer la date avant le regroupement pour ne compter qu'une seule connexion par jour ...
SELECT UserId from History
WHERE CreationDate > ( now() - n )
GROUP BY UserId,
DATEADD(dd, DATEDIFF(dd, 0, CreationDate), 0) AS TruncatedCreationDate
HAVING COUNT(TruncatedCreationDate) >= n
EDITED pour utiliser DATEADD (dd, DATEDIFF (dd, 0, CreationDate), 0) au lieu de convertir (char (10), CreationDate, 101).
@IDisposable Je cherchais à utiliser datepart plus tôt, mais j'étais trop paresseux pour rechercher la syntaxe, j'ai donc pensé utiliser plutôt convert. Je sais que cela a eu un impact significatif Merci! maintenant je sais.
en supposant un schéma qui va comme:
create table dba.visits
(
id integer not null,
user_id integer not null,
creation_date date not null
);
cela va extraire les plages contiguës d'une séquence de dates avec des lacunes.
select l.creation_date as start_d, -- Get first date in contiguous range
(
select min(a.creation_date ) as creation_date
from "DBA"."visits" a
left outer join "DBA"."visits" b on
a.creation_date = dateadd(day, -1, b.creation_date ) and
a.user_id = b.user_id
where b.creation_date is null and
a.creation_date >= l.creation_date and
a.user_id = l.user_id
) as end_d -- Get last date in contiguous range
from "DBA"."visits" l
left outer join "DBA"."visits" r on
r.creation_date = dateadd(day, -1, l.creation_date ) and
r.user_id = l.user_id
where r.creation_date is null
Spencer l'a presque fait, mais cela devrait être le code de travail:
SELECT DISTINCT UserId
FROM History h1
WHERE (
SELECT COUNT(*)
FROM History
WHERE UserId = h1.UserId AND CreationDate BETWEEN h1.CreationDate AND DATEADD(d, @n-1, h1.CreationDate)
) >= @n
Du haut de ma tête, MySQLish:
SELECT start.UserId
FROM UserHistory AS start
LEFT OUTER JOIN UserHistory AS pre_start ON pre_start.UserId=start.UserId
AND DATE(pre_start.CreationDate)=DATE_SUB(DATE(start.CreationDate), INTERVAL 1 DAY)
LEFT OUTER JOIN UserHistory AS subsequent ON subsequent.UserId=start.UserId
AND DATE(subsequent.CreationDate)<=DATE_ADD(DATE(start.CreationDate), INTERVAL 30 DAY)
WHERE pre_start.Id IS NULL
GROUP BY start.Id
HAVING COUNT(subsequent.Id)=30
Non testé, et a presque certainement besoin d'une conversion pour MSSQL, mais je pense que cela donne quelques idées.
Que diriez-vous d'un utilisant des tables Tally? Il suit une approche plus algorithmique et le plan d'exécution est un jeu d'enfant. Remplissez le tableau de pointage avec des nombres de 1 à "MaxDaysBehind" que vous souhaitez analyser la table (par exemple. 90 cherchera 3 mois de retard, etc.).
declare @ContinousDays int
set @ContinousDays = 30 -- select those that have 30 consecutive days
create table #tallyTable (Tally int)
insert into #tallyTable values (1)
...
insert into #tallyTable values (90) -- insert numbers for as many days behind as you want to scan
select [UserId],count(*),t.Tally from HistoryTable
join #tallyTable as t on t.Tally>0
where [CreationDate]> getdate()[email protected] and
[CreationDate]<getdate()-t.Tally
group by [UserId],t.Tally
having count(*)>=@ContinousDays
delete #tallyTable